keyforge-anvil-client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/package.json +55 -0
- package/src/activate.js +123 -0
- package/src/clock/rollback.js +40 -0
- package/src/crypto/errors.js +42 -0
- package/src/crypto/keys.js +42 -0
- package/src/crypto/verify.js +117 -0
- package/src/deactivate.js +47 -0
- package/src/entitlement.js +137 -0
- package/src/index.js +58 -0
- package/src/network/errors.js +23 -0
- package/src/network/request.js +61 -0
- package/src/refresh.js +125 -0
- package/src/storage/adapter.js +33 -0
- package/src/storage/json-file.js +78 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ilyasse-fouaide
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# keyforge-anvil-client
|
|
2
|
+
|
|
3
|
+
Offline-safe license client for keyforge-anvil-protected branch installations.
|
|
4
|
+
|
|
5
|
+
`keyforge-anvil-client` is a Node/ESM module that runs *inside* a branch's local
|
|
6
|
+
backend process to talk to keyforge-anvil (the licensing server, a separate
|
|
7
|
+
repo). It is not a browser client — no UI, no framework dependency. Local
|
|
8
|
+
Ed25519 signature verification is the fast, network-free path;
|
|
9
|
+
server contact (`activate`/`refresh`/`deactivate`) is one-time or
|
|
10
|
+
background. **The branch app must never block startup on a network call** —
|
|
11
|
+
`getEntitlement()` never makes one.
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install keyforge-anvil-client
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```js
|
|
22
|
+
import { createKeyforgeClient } from 'keyforge-anvil-client';
|
|
23
|
+
|
|
24
|
+
const client = await createKeyforgeClient({
|
|
25
|
+
publicKeys: { 1: process.env.KEYFORGE_PUBLIC_KEY_V1 },
|
|
26
|
+
baseUrl: 'https://licensing.example.com',
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Once, human-initiated (e.g. from a setup wizard). Throws on failure.
|
|
30
|
+
await client.activate(licenseKey);
|
|
31
|
+
|
|
32
|
+
// On every app boot. Network-free, never throws for expected bad states.
|
|
33
|
+
const entitlement = await client.getEntitlement();
|
|
34
|
+
if (entitlement.status !== 'valid') {
|
|
35
|
+
// degraded mode vs. hard stop is your app's call, not this module's
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Periodically in the background (e.g. every few hours). Never throws for
|
|
39
|
+
// "offline" — silently no-ops so it's safe to call on a timer.
|
|
40
|
+
setInterval(() => client.refresh(), 6 * 60 * 60 * 1000);
|
|
41
|
+
|
|
42
|
+
// Decommissioning a branch. Throws on failure.
|
|
43
|
+
await client.deactivate();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
`createKeyforgeClient(config)` takes a single config object:
|
|
49
|
+
|
|
50
|
+
| Field | Required | Default | Notes |
|
|
51
|
+
|---|---|---|---|
|
|
52
|
+
| `publicKeys` | Yes | — | `{ [keyVersion]: pemString }`. keyforge-anvil's Ed25519 public key(s), keyed by `keyVersion` for rotation. Config, not hardcoded, so a server-side key rotation doesn't force a new release of this module. |
|
|
53
|
+
| `baseUrl` | Yes | — | keyforge-anvil server base URL, e.g. `https://licensing.example.com`. |
|
|
54
|
+
| `storage` | No | a JSON-file adapter at `<cwd>/.keyforge-client/state.json` | Any object implementing the `StorageAdapter` interface (`get`/`set`/`delete`, all `Promise`-returning). The default is a plain JSON file — no SQLite, no native-binary install friction. If your backend already manages its own database, implement `StorageAdapter` against it instead; pass an explicit instance (e.g. `import { createJsonFileAdapter } from 'keyforge-anvil-client'; createJsonFileAdapter({ filePath })`) to change the default file's location. |
|
|
55
|
+
| `getNow` | No | real clock (unix seconds) | Injectable clock seam, mainly useful for tests. |
|
|
56
|
+
| `fetchImpl` | No | global `fetch` | Injectable fetch seam, mainly useful for tests. |
|
|
57
|
+
|
|
58
|
+
The returned client exposes exactly four functions: `activate(licenseKey)`,
|
|
59
|
+
`getEntitlement()`, `refresh()`, `deactivate()` — matching the four
|
|
60
|
+
lifecycle operations above.
|
|
61
|
+
|
|
62
|
+
## `installationFingerprint`
|
|
63
|
+
|
|
64
|
+
A random UUID generated transparently the first time `activate()` is
|
|
65
|
+
called, and persisted under the `installationFingerprint` storage key (it
|
|
66
|
+
is not part of any status object `getEntitlement()`/`refresh()` return).
|
|
67
|
+
It identifies *this device*, not a particular activation:
|
|
68
|
+
|
|
69
|
+
- It is reused unchanged on any later `activate()` call from the same
|
|
70
|
+
installation, which the server uses for idempotent reactivation.
|
|
71
|
+
- It **survives `deactivate()`** deliberately — `deactivate()` clears
|
|
72
|
+
license-activation state (tokens, watermarks, the `revoked` flag) but
|
|
73
|
+
intentionally keeps the fingerprint, since decommissioning a license
|
|
74
|
+
isn't the same event as the device itself changing identity.
|
|
75
|
+
|
|
76
|
+
## Status and error vocabulary
|
|
77
|
+
|
|
78
|
+
### `getEntitlement()` statuses
|
|
79
|
+
|
|
80
|
+
Always network-free, never throws for any of these — an unexpected storage
|
|
81
|
+
error (e.g. corrupted state file) propagates instead of becoming a status.
|
|
82
|
+
|
|
83
|
+
| Status | Meaning |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `not_activated` | No stored entitlement token — `activate()` hasn't run yet, or `deactivate()` cleared it. |
|
|
86
|
+
| `valid` | Signature, expiry, clock, and replay checks all pass. Returns `{ status, expiresAt, featureIds, features }` — `featureIds` is the token's array of licensed feature ids (keyforge-anvil's replacement for the old single `productId` claim); `features` is the open-ended capability map (currently `{ maxBranches }`). |
|
|
87
|
+
| `expired` | Token's signature is valid but it's past `expiresAt`. |
|
|
88
|
+
| `revoked` | The server reported a revocation on a past `refresh()` call — see [Revocation propagation](#revocation-propagation) below. |
|
|
89
|
+
| `tampered` | Signature invalid, payload malformed, `installationId` doesn't match this installation, or the token is a replay of an already-superseded one. |
|
|
90
|
+
| `unknown_key_version` | Token's `kid` isn't in the `publicKeys` this client was configured with — usually means local config is behind a server-side key rotation, not a fraudulent token. |
|
|
91
|
+
| `clock_rollback` | Local clock is behind the last recorded validation time. |
|
|
92
|
+
|
|
93
|
+
### `KeyforgeApiError` codes
|
|
94
|
+
|
|
95
|
+
Thrown by `activate()`/`refresh()`/`deactivate()` for genuinely unexpected
|
|
96
|
+
outcomes (never for `refresh()`'s expected "offline"/"rate limited" cases,
|
|
97
|
+
which resolve to `{ status: 'offline' }` instead of throwing).
|
|
98
|
+
|
|
99
|
+
**Client-side-detected** — these are synthetic codes this library produces
|
|
100
|
+
locally; they are never returned by the keyforge-anvil server, so you won't find
|
|
101
|
+
them in keyforge-anvil's own API docs:
|
|
102
|
+
|
|
103
|
+
| Code | Meaning |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `MALFORMED_RESPONSE` | A 2xx response body was missing required fields or wasn't valid JSON. |
|
|
106
|
+
| `INSTALLATION_ID_MISMATCH` | A response's `installationToken`/entitlement token disagree on `installationId`. |
|
|
107
|
+
| `STALE_TOKEN_REPLAY` | A response's token is not newer than the last one this installation accepted — rejects replayed/captured old responses. |
|
|
108
|
+
|
|
109
|
+
**Server-reported** — `error.code` is passed through verbatim from
|
|
110
|
+
keyforge-anvil's own error vocabulary (this library never invents a parallel
|
|
111
|
+
vocabulary for these). A representative, non-exhaustive sample seen in this
|
|
112
|
+
codebase's tests: `LICENSE_INVALID`, `LICENSE_REVOKED`, `RATE_LIMITED`,
|
|
113
|
+
`INSTALLATION_TOKEN_INVALID`. keyforge-anvil's own docs are the authoritative,
|
|
114
|
+
complete list.
|
|
115
|
+
|
|
116
|
+
One related detail: the same underlying "token failed local verification"
|
|
117
|
+
condition surfaces two different ways depending on which function hits it —
|
|
118
|
+
`activate()` lets `TokenInvalidError`/`TokenExpiredError`/
|
|
119
|
+
`UnknownKeyVersionError` (from local crypto verification) propagate as
|
|
120
|
+
thrown errors, while `getEntitlement()` converts the identical condition
|
|
121
|
+
into a status string (`tampered`/`expired`/`unknown_key_version`) instead
|
|
122
|
+
of throwing. This follows from each function's own contract (`activate()`
|
|
123
|
+
is a one-time action that should throw; `getEntitlement()` reports state
|
|
124
|
+
and never throws for expected bad states), not an inconsistency.
|
|
125
|
+
|
|
126
|
+
## Revocation propagation
|
|
127
|
+
|
|
128
|
+
`getEntitlement()` runs entirely offline. Run purely offline, it cannot
|
|
129
|
+
know about a revocation that happened after the last successful
|
|
130
|
+
`refresh()` — the server can't tell a client something it hasn't
|
|
131
|
+
contacted. This is inherited by design from the keyforge-anvil server's own
|
|
132
|
+
architecture (revocation propagates only when a client reaches the
|
|
133
|
+
server, bounded by the entitlement token's expiry window); it is not a
|
|
134
|
+
defect in this client. Call `refresh()` periodically in the background to
|
|
135
|
+
bound how stale that window can get.
|
|
136
|
+
|
|
137
|
+
## Accepted limitations
|
|
138
|
+
|
|
139
|
+
Documented here briefly; see `PROGRESS.md` for the full rationale behind
|
|
140
|
+
each (found and evaluated during this project's per-phase security
|
|
141
|
+
reviews):
|
|
142
|
+
|
|
143
|
+
- **Clock+watermark co-tampering** — an attacker with local filesystem
|
|
144
|
+
write access (the project's existing trust boundary) can roll the clock
|
|
145
|
+
back and edit the local rollback watermark together, defeating both
|
|
146
|
+
checks for an otherwise-genuine token.
|
|
147
|
+
- **Unbounded response body size** — no cap on `entitlementToken`/
|
|
148
|
+
`installationToken` string sizes accepted from a response before
|
|
149
|
+
verification.
|
|
150
|
+
- **Multi-write non-atomicity** — `activate()`/`refresh()` each issue
|
|
151
|
+
several independent storage writes; a process killed mid-sequence can
|
|
152
|
+
leave state requiring a retry (fails closed, not open).
|
|
153
|
+
- **`installationFingerprint`-seeding TOCTOU** — two concurrent, first-ever
|
|
154
|
+
`activate()` calls on the same client could each generate a different
|
|
155
|
+
fingerprint, with the second write silently winning.
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
- `npm test` / `npm run test:watch` — Vitest
|
|
160
|
+
- `npm run lint` / `npm run lint:fix` — ESLint
|
|
161
|
+
- `npm run format` / `npm run format:check` — Prettier
|
|
162
|
+
|
|
163
|
+
See `CLAUDE.md` for the full command list and `ARCHITECTURE.md`/
|
|
164
|
+
`PROGRESS.md` for design decisions and phase-by-phase history.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT — see [LICENSE](./LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "keyforge-anvil-client",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Offline-safe license client for keyforge-anvil-protected branch installations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "ilyasse-fouaide <ilyasse.fouaide@gmail.com>",
|
|
8
|
+
"homepage": "https://github.com/Ilyasse-Fouaide/keyforge-anvil-client#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Ilyasse-Fouaide/keyforge-anvil-client.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Ilyasse-Fouaide/keyforge-anvil-client/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"license",
|
|
18
|
+
"licensing",
|
|
19
|
+
"entitlement",
|
|
20
|
+
"offline",
|
|
21
|
+
"ed25519",
|
|
22
|
+
"keyforge",
|
|
23
|
+
"keyforge-anvil"
|
|
24
|
+
],
|
|
25
|
+
"main": "./src/index.js",
|
|
26
|
+
"exports": "./src/index.js",
|
|
27
|
+
"files": [
|
|
28
|
+
"src"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=24"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:watch": "vitest",
|
|
36
|
+
"lint": "eslint .",
|
|
37
|
+
"lint:fix": "eslint . --fix",
|
|
38
|
+
"format": "prettier --write .",
|
|
39
|
+
"format:check": "prettier --check .",
|
|
40
|
+
"examples:setup": "node examples/setup-fixtures.js",
|
|
41
|
+
"examples:teardown": "node examples/teardown-fixtures.js",
|
|
42
|
+
"examples:run-all": "node examples/run-all.js"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"jose": "^6.2.8"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@eslint/js": "^10.0.1",
|
|
49
|
+
"eslint": "^10.8.1",
|
|
50
|
+
"eslint-config-prettier": "^10.1.8",
|
|
51
|
+
"globals": "^17.9.0",
|
|
52
|
+
"prettier": "^3.9.6",
|
|
53
|
+
"vitest": "^4.1.10"
|
|
54
|
+
}
|
|
55
|
+
}
|
package/src/activate.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Phase 3 — POST /activate, store tokens via the storage adapter.
|
|
2
|
+
//
|
|
3
|
+
// Closes three of Phase 2's carry-forward items (PROGRESS.md): writes the
|
|
4
|
+
// initial lastValidatedAt watermark (without it every post-activation
|
|
5
|
+
// getEntitlement() call fails closed with clock_rollback, permanently),
|
|
6
|
+
// seeds highestIssuedAtSeen, and stores the installationId entitlement.js
|
|
7
|
+
// now checks against. A human-initiated, one-time action — throws on any
|
|
8
|
+
// failure per ARCHITECTURE.md §4, never returns a status object.
|
|
9
|
+
//
|
|
10
|
+
// The received entitlementToken is verified locally (same crypto/ machinery
|
|
11
|
+
// getEntitlement() uses) before anything is persisted. This is the load-
|
|
12
|
+
// bearing defense against a MITM or malicious server: a compromised network
|
|
13
|
+
// path can return whatever JSON it likes, but it cannot forge a token that
|
|
14
|
+
// verifies against our configured public keys, so nothing this function
|
|
15
|
+
// stores can end up trusted-but-fraudulent.
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
import { loadPublicKeys } from './crypto/keys.js';
|
|
20
|
+
import { verifyEntitlementToken } from './crypto/verify.js';
|
|
21
|
+
import { KeyforgeApiError } from './network/errors.js';
|
|
22
|
+
import { apiErrorFromResponse, parseSuccessBody, postJson } from './network/request.js';
|
|
23
|
+
|
|
24
|
+
const defaultGetNow = () => Math.floor(Date.now() / 1000);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} options
|
|
28
|
+
* @param {import('./storage/adapter.js').StorageAdapter} options.storage
|
|
29
|
+
* @param {Record<string, string>} options.publicKeys - keyVersion -> PEM string
|
|
30
|
+
* @param {string} options.baseUrl - Keyforge base URL, e.g. 'https://licensing.example.com'
|
|
31
|
+
* @param {() => number} [options.getNow]
|
|
32
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
33
|
+
* @returns {Promise<{ activate: (licenseKey: string) => Promise<{ expiresAt: number }> }>}
|
|
34
|
+
*/
|
|
35
|
+
export async function createActivateClient({
|
|
36
|
+
storage,
|
|
37
|
+
publicKeys,
|
|
38
|
+
baseUrl,
|
|
39
|
+
getNow = defaultGetNow,
|
|
40
|
+
fetchImpl = fetch,
|
|
41
|
+
}) {
|
|
42
|
+
const publicKeysByVersion = await loadPublicKeys(publicKeys);
|
|
43
|
+
|
|
44
|
+
async function activate(licenseKey) {
|
|
45
|
+
let installationFingerprint = await storage.get('installationFingerprint');
|
|
46
|
+
if (installationFingerprint === null) {
|
|
47
|
+
installationFingerprint = randomUUID();
|
|
48
|
+
await storage.set('installationFingerprint', installationFingerprint);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const response = await postJson(
|
|
52
|
+
baseUrl,
|
|
53
|
+
'/api/v1/licenses/activate',
|
|
54
|
+
{ licenseKey, installationFingerprint },
|
|
55
|
+
fetchImpl,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
if (response.status !== 201) {
|
|
59
|
+
throw await apiErrorFromResponse(response);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const body = await parseSuccessBody(response);
|
|
63
|
+
const { entitlementToken, installationToken, installationId } = body?.data ?? {};
|
|
64
|
+
if (
|
|
65
|
+
typeof entitlementToken !== 'string' ||
|
|
66
|
+
typeof installationToken !== 'string' ||
|
|
67
|
+
typeof installationId !== 'string' ||
|
|
68
|
+
installationId.length === 0
|
|
69
|
+
) {
|
|
70
|
+
throw new KeyforgeApiError(
|
|
71
|
+
response.status,
|
|
72
|
+
'MALFORMED_RESPONSE',
|
|
73
|
+
'activate() response is missing required fields',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const now = getNow();
|
|
78
|
+
// Verify before trusting anything else in the response — see module
|
|
79
|
+
// comment. Left to propagate as-is (TokenInvalidError/TokenExpiredError/
|
|
80
|
+
// UnknownKeyVersionError): a malformed/malicious/stale-key response here
|
|
81
|
+
// is a genuine failure this one-time action should throw for, not a
|
|
82
|
+
// status this module invents a mapping for.
|
|
83
|
+
const payload = await verifyEntitlementToken(entitlementToken, { publicKeysByVersion, now });
|
|
84
|
+
|
|
85
|
+
if (String(payload.installationId) !== installationId) {
|
|
86
|
+
throw new KeyforgeApiError(
|
|
87
|
+
response.status,
|
|
88
|
+
'INSTALLATION_ID_MISMATCH',
|
|
89
|
+
'activate() response installationId does not match the entitlement token payload',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Reject a stale or replayed response BEFORE persisting anything, same
|
|
94
|
+
// defense as refresh() (see its module comment) — guards an
|
|
95
|
+
// already-active installation against a MITM/malicious server replaying
|
|
96
|
+
// an old, still-validly-signed /activate response. Only meaningful when
|
|
97
|
+
// there's a prior watermark to compare against: a genuinely fresh
|
|
98
|
+
// device, or one that just ran deactivate() (which clears this key),
|
|
99
|
+
// has nothing to compare yet — same "absence isn't fail-closed"
|
|
100
|
+
// reasoning entitlement.js already documents for this same field.
|
|
101
|
+
const storedHighestIssuedAt = await storage.get('highestIssuedAtSeen');
|
|
102
|
+
const highestIssuedAtSeen =
|
|
103
|
+
storedHighestIssuedAt === null ? null : Number(storedHighestIssuedAt);
|
|
104
|
+
if (highestIssuedAtSeen !== null && payload.issuedAt <= highestIssuedAtSeen) {
|
|
105
|
+
throw new KeyforgeApiError(
|
|
106
|
+
response.status,
|
|
107
|
+
'STALE_TOKEN_REPLAY',
|
|
108
|
+
'activate() response entitlementToken is not newer than the last one seen',
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await storage.set('entitlementToken', entitlementToken);
|
|
113
|
+
await storage.set('installationToken', installationToken);
|
|
114
|
+
await storage.set('installationId', payload.installationId);
|
|
115
|
+
await storage.set('lastValidatedAt', String(now));
|
|
116
|
+
await storage.set('highestIssuedAtSeen', String(payload.issuedAt));
|
|
117
|
+
await storage.delete('revoked');
|
|
118
|
+
|
|
119
|
+
return { expiresAt: payload.expiresAt };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { activate };
|
|
123
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Phase 2 — ported from Keyforge server's tests/helpers/offlineClock.js
|
|
2
|
+
// (assertNoClockRollback), unchanged. That file lives under the server
|
|
3
|
+
// repo's tests/helpers/ because it has no in-repo caller there — it's a
|
|
4
|
+
// tested reference implementation for the client SDK that didn't exist yet.
|
|
5
|
+
// This module is that client, so the check becomes real production code.
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Deliberately extends bare Error, not TokenVerificationError: this check
|
|
9
|
+
* runs before verifyEntitlementToken is ever called, and rejects for a
|
|
10
|
+
* reason crypto/ has no visibility into (local trust state, not the
|
|
11
|
+
* token's own signature/schema/expiry).
|
|
12
|
+
*/
|
|
13
|
+
export class ClockRollbackDetectedError extends Error {
|
|
14
|
+
constructor(now, lastValidatedAt) {
|
|
15
|
+
super(`System clock (${now}) is behind the last recorded validation time (${lastValidatedAt})`);
|
|
16
|
+
this.name = this.constructor.name;
|
|
17
|
+
this.now = now;
|
|
18
|
+
this.lastValidatedAt = lastValidatedAt;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Requires both values to already be finite numbers and throws a plain
|
|
24
|
+
* TypeError otherwise, rather than letting `<` silently coerce (e.g.
|
|
25
|
+
* `now < undefined` is false, so a caller whose storage read for the
|
|
26
|
+
* watermark came back empty would otherwise see "no rollback detected"
|
|
27
|
+
* and fall through to trusting the cached token — exactly backwards for a
|
|
28
|
+
* check whose whole point is to fail closed on an ambiguous clock state).
|
|
29
|
+
* A TypeError, not ClockRollbackDetectedError, since this is a contract
|
|
30
|
+
* violation by the caller, not a rollback detection.
|
|
31
|
+
*/
|
|
32
|
+
export function assertNoClockRollback({ now, lastValidatedAt }) {
|
|
33
|
+
if (!Number.isFinite(now) || !Number.isFinite(lastValidatedAt)) {
|
|
34
|
+
throw new TypeError('assertNoClockRollback requires now and lastValidatedAt as finite numbers');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (now < lastValidatedAt) {
|
|
38
|
+
throw new ClockRollbackDetectedError(now, lastValidatedAt);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Phase 2 — ported from the keyforge server's src/crypto/errors.js, unchanged
|
|
2
|
+
// (keyforge-anvil's equivalent is identical).
|
|
3
|
+
//
|
|
4
|
+
// Deliberately does NOT extend a shared app-wide error base: this stays
|
|
5
|
+
// framework-agnostic, and translating these into keyforge-anvil-client's own
|
|
6
|
+
// status-object vocabulary is entitlement.js's job, not this file's.
|
|
7
|
+
//
|
|
8
|
+
// "Malformed" and "tampered" both collapse into TokenInvalidError rather
|
|
9
|
+
// than getting their own classes — both mean "reject, don't trust this
|
|
10
|
+
// token," and no caller needs to treat them differently. TokenExpiredError
|
|
11
|
+
// and UnknownKeyVersionError stay distinct because a caller plausibly does
|
|
12
|
+
// want to handle those differently (prompt a refresh vs. flag a stale
|
|
13
|
+
// embedded key).
|
|
14
|
+
|
|
15
|
+
export class TokenVerificationError extends Error {
|
|
16
|
+
constructor(message, options) {
|
|
17
|
+
super(message, options);
|
|
18
|
+
this.name = this.constructor.name;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class TokenInvalidError extends TokenVerificationError {
|
|
23
|
+
code = 'TOKEN_INVALID';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class TokenExpiredError extends TokenVerificationError {
|
|
27
|
+
code = 'TOKEN_EXPIRED';
|
|
28
|
+
|
|
29
|
+
constructor(expiresAt, options) {
|
|
30
|
+
super(`Token expired at ${expiresAt}`, options);
|
|
31
|
+
this.expiresAt = expiresAt;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class UnknownKeyVersionError extends TokenVerificationError {
|
|
36
|
+
code = 'UNKNOWN_KEY_VERSION';
|
|
37
|
+
|
|
38
|
+
constructor(keyVersion, options) {
|
|
39
|
+
super(`Unknown signing key version: ${keyVersion}`, options);
|
|
40
|
+
this.keyVersion = keyVersion;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Phase 2 — new helper, not a port. Keyforge server loads public keys from
|
|
2
|
+
// a file-path manifest (its own src/crypto/keys.js); this module's own §8
|
|
3
|
+
// resolution is inline PEM strings supplied at init instead (see
|
|
4
|
+
// ARCHITECTURE.md §8 and PROGRESS.md's Phase 2 entry for the rationale) —
|
|
5
|
+
// this file only owns the PEM-string -> CryptoKey conversion, no file I/O.
|
|
6
|
+
|
|
7
|
+
import { importSPKI } from 'jose';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Converts a `{ [keyVersion]: pemString }` config into the
|
|
11
|
+
* `Map<string, CryptoKey>` shape verifyEntitlementToken expects, keyed by
|
|
12
|
+
* version-as-string to match the JWS `kid` header (spec-string-typed).
|
|
13
|
+
* @param {Record<string, string>} publicKeys
|
|
14
|
+
* @returns {Promise<Map<string, CryptoKey>>}
|
|
15
|
+
*/
|
|
16
|
+
export async function loadPublicKeys(publicKeys) {
|
|
17
|
+
if (publicKeys === null || typeof publicKeys !== 'object' || Array.isArray(publicKeys)) {
|
|
18
|
+
throw new TypeError('publicKeys must be an object mapping keyVersion to a PEM string');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const entries = Object.entries(publicKeys);
|
|
22
|
+
if (entries.length === 0) {
|
|
23
|
+
throw new TypeError('publicKeys must contain at least one key');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const resolved = await Promise.all(
|
|
27
|
+
entries.map(async ([version, pem]) => {
|
|
28
|
+
if (typeof pem !== 'string' || pem.length === 0) {
|
|
29
|
+
throw new TypeError(`publicKeys['${version}'] must be a non-empty PEM string`);
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
return [version, await importSPKI(pem, 'EdDSA')];
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new TypeError(`publicKeys['${version}'] is not a valid EdDSA public key PEM`, {
|
|
35
|
+
cause: err,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return new Map(resolved);
|
|
42
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Phase 2 — ported from Keyforge server's src/crypto/verify.js. Same jose
|
|
2
|
+
// call (compactVerify, not jwtVerify — the payload uses this project's own
|
|
3
|
+
// field names, not registered JWT claims), same error dispatch order, same
|
|
4
|
+
// algorithm pin. The one substantive change: the server validates the
|
|
5
|
+
// parsed payload with a zod schema (entitlementToken.schema.js); this module
|
|
6
|
+
// has no zod dependency (deliberately dependency-light — see ARCHITECTURE.md
|
|
7
|
+
// §3), so assertValidPayloadShape below checks only the one field this
|
|
8
|
+
// module's own logic branches on (expiresAt) instead of porting the full
|
|
9
|
+
// schema. Signature verification already proves authenticity; the server
|
|
10
|
+
// validated shape at signing time.
|
|
11
|
+
|
|
12
|
+
import { compactVerify } from 'jose';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
TokenVerificationError,
|
|
16
|
+
TokenInvalidError,
|
|
17
|
+
TokenExpiredError,
|
|
18
|
+
UnknownKeyVersionError,
|
|
19
|
+
} from './errors.js';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Verifies a signed entitlement token: signature, payload shape, and expiry.
|
|
23
|
+
* Rejects tampered payloads, expired tokens, and unknown key versions with
|
|
24
|
+
* distinct error types — everything else (malformed structure, bad
|
|
25
|
+
* signature, shape violations) collapses into TokenInvalidError, since none
|
|
26
|
+
* of those need different handling downstream.
|
|
27
|
+
*
|
|
28
|
+
* Key resolution happens via jose's own GetKeyFunction callback, keyed on
|
|
29
|
+
* the header's `kid`: an error thrown inside that callback
|
|
30
|
+
* (UnknownKeyVersionError below) propagates through compactVerify()
|
|
31
|
+
* unmodified, with nothing verified yet at the time it runs. That's why the
|
|
32
|
+
* outer catch checks `instanceof TokenVerificationError` first — without
|
|
33
|
+
* that check, a naive wrap would misreport an unknown key version as a
|
|
34
|
+
* generic invalid-token failure.
|
|
35
|
+
*/
|
|
36
|
+
export async function verifyEntitlementToken(
|
|
37
|
+
jws,
|
|
38
|
+
{ publicKeysByVersion, now = Math.floor(Date.now() / 1000) },
|
|
39
|
+
) {
|
|
40
|
+
const resolveKey = (protectedHeader) => {
|
|
41
|
+
const { kid } = protectedHeader;
|
|
42
|
+
if (typeof kid !== 'string' || kid.length === 0) {
|
|
43
|
+
throw new TokenInvalidError('Token header is missing kid');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const key = publicKeysByVersion.get(kid);
|
|
47
|
+
if (!key) {
|
|
48
|
+
throw new UnknownKeyVersionError(kid);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return key;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
let payloadBytes;
|
|
55
|
+
let protectedHeader;
|
|
56
|
+
try {
|
|
57
|
+
// Pinning algorithms prevents algorithm-confusion attacks (e.g. a
|
|
58
|
+
// crafted alg: 'none' or alg: 'HS256' token). For Ed25519 keys
|
|
59
|
+
// specifically, jose already independently refuses both of those cases
|
|
60
|
+
// via its own type checking, so this pin is currently redundant with
|
|
61
|
+
// that — kept anyway as explicit, load-bearing-by-design defense in
|
|
62
|
+
// depth rather than an incidental side effect of jose's internals, and
|
|
63
|
+
// because it stops being redundant the moment this module ever has to
|
|
64
|
+
// deal with more than one key type.
|
|
65
|
+
({ payload: payloadBytes, protectedHeader } = await compactVerify(jws, resolveKey, {
|
|
66
|
+
algorithms: ['EdDSA'],
|
|
67
|
+
}));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (err instanceof TokenVerificationError) throw err;
|
|
70
|
+
throw new TokenInvalidError('Token signature verification failed', { cause: err });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let payload;
|
|
74
|
+
try {
|
|
75
|
+
payload = JSON.parse(new TextDecoder().decode(payloadBytes));
|
|
76
|
+
assertValidPayloadShape(payload);
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new TokenInvalidError('Token payload invalid', { cause: err });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Header and payload are both covered by the signature, so these can't
|
|
82
|
+
// actually diverge post-verification given the server is the only writer
|
|
83
|
+
// — cheap to assert anyway as a backstop against a future writer that
|
|
84
|
+
// isn't.
|
|
85
|
+
if (String(payload.keyVersion) !== protectedHeader.kid) {
|
|
86
|
+
throw new TokenInvalidError('Token keyVersion does not match header kid');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (now > payload.expiresAt) {
|
|
90
|
+
throw new TokenExpiredError(payload.expiresAt);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return payload;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Minimal shape guard, scoped to only what this module consumes.
|
|
98
|
+
* `expiresAt` must be checked explicitly: `now > payload.expiresAt` is
|
|
99
|
+
* `false` when `expiresAt` is `undefined` (or any other non-number), which
|
|
100
|
+
* would otherwise make a malformed token silently read as "never expires"
|
|
101
|
+
* instead of being rejected — the same silent-permissive-fallthrough shape
|
|
102
|
+
* as the clock-rollback bug this module's clock/ guards against.
|
|
103
|
+
* `keyVersion` needs no separate check — the cross-check above already
|
|
104
|
+
* rejects a missing/undefined value, since `String(undefined)` can't match
|
|
105
|
+
* a real `kid`. `featureIds` (keyforge-anvil's replacement for the old
|
|
106
|
+
* single `productId` claim) and `features` are both passed through opaquely
|
|
107
|
+
* and unvalidated: neither is compared or branched on here, only relayed to
|
|
108
|
+
* the caller (`entitlement.js` surfaces both on a `valid` status).
|
|
109
|
+
*/
|
|
110
|
+
function assertValidPayloadShape(payload) {
|
|
111
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
112
|
+
throw new TypeError('Entitlement token payload must be a JSON object');
|
|
113
|
+
}
|
|
114
|
+
if (!Number.isFinite(payload.expiresAt)) {
|
|
115
|
+
throw new TypeError('Entitlement token payload is missing a valid expiresAt');
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Phase 3 — POST /deactivate for branch decommissioning.
|
|
2
|
+
//
|
|
3
|
+
// A deliberate, human-initiated action — throws on any failure (including
|
|
4
|
+
// network unreachability, unlike refresh()) per ARCHITECTURE.md §4. On
|
|
5
|
+
// success, clears all locally stored license-activation state so a
|
|
6
|
+
// subsequent getEntitlement() call reports the existing 'not_activated'
|
|
7
|
+
// status — no new vocabulary needed. installationFingerprint is
|
|
8
|
+
// deliberately kept: it identifies this device, not this activation, and
|
|
9
|
+
// survives across a future re-activation.
|
|
10
|
+
|
|
11
|
+
import { apiErrorFromResponse, postJson } from './network/request.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} options
|
|
15
|
+
* @param {import('./storage/adapter.js').StorageAdapter} options.storage
|
|
16
|
+
* @param {string} options.baseUrl
|
|
17
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
18
|
+
* @returns {Promise<{ deactivate: () => Promise<void> }>}
|
|
19
|
+
*/
|
|
20
|
+
export async function createDeactivateClient({ storage, baseUrl, fetchImpl = fetch }) {
|
|
21
|
+
async function deactivate() {
|
|
22
|
+
const installationToken = await storage.get('installationToken');
|
|
23
|
+
if (installationToken === null) {
|
|
24
|
+
return; // nothing to deactivate
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const response = await postJson(
|
|
28
|
+
baseUrl,
|
|
29
|
+
'/api/v1/licenses/deactivate',
|
|
30
|
+
{ installationToken },
|
|
31
|
+
fetchImpl,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (response.status !== 204) {
|
|
35
|
+
throw await apiErrorFromResponse(response);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
await storage.delete('entitlementToken');
|
|
39
|
+
await storage.delete('installationToken');
|
|
40
|
+
await storage.delete('installationId');
|
|
41
|
+
await storage.delete('lastValidatedAt');
|
|
42
|
+
await storage.delete('highestIssuedAtSeen');
|
|
43
|
+
await storage.delete('revoked');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { deactivate };
|
|
47
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Phase 2 — getEntitlement(): local verification composition.
|
|
2
|
+
//
|
|
3
|
+
// Composes clock-rollback detection with signature verification, in that
|
|
4
|
+
// order — ported from Keyforge server's tests/offline-flow/clientVerification.test.js
|
|
5
|
+
// verifyStoredToken helper: the clock check is a bare, unguarded statement
|
|
6
|
+
// that runs to completion (or throws) before verifyEntitlementToken is ever
|
|
7
|
+
// called. Translated here from that file's exception-based contract into
|
|
8
|
+
// this module's own status-object vocabulary (ARCHITECTURE.md §4/§9).
|
|
9
|
+
|
|
10
|
+
import { assertNoClockRollback, ClockRollbackDetectedError } from './clock/rollback.js';
|
|
11
|
+
import { TokenExpiredError, TokenInvalidError, UnknownKeyVersionError } from './crypto/errors.js';
|
|
12
|
+
import { loadPublicKeys } from './crypto/keys.js';
|
|
13
|
+
import { verifyEntitlementToken } from './crypto/verify.js';
|
|
14
|
+
|
|
15
|
+
const defaultGetNow = () => Math.floor(Date.now() / 1000);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} options
|
|
19
|
+
* @param {import('./storage/adapter.js').StorageAdapter} options.storage
|
|
20
|
+
* @param {Record<string, string>} options.publicKeys - keyVersion -> PEM string
|
|
21
|
+
* @param {() => number} [options.getNow] - injectable clock seam for tests; defaults to real time (unix seconds)
|
|
22
|
+
* @returns {Promise<{ getEntitlement: () => Promise<object> }>}
|
|
23
|
+
*/
|
|
24
|
+
export async function createEntitlementChecker({ storage, publicKeys, getNow = defaultGetNow }) {
|
|
25
|
+
const publicKeysByVersion = await loadPublicKeys(publicKeys);
|
|
26
|
+
|
|
27
|
+
async function getEntitlement() {
|
|
28
|
+
const tokenStr = await storage.get('entitlementToken');
|
|
29
|
+
if (tokenStr === null) {
|
|
30
|
+
return { status: 'not_activated' };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// A server-reported revocation (set by refresh() on a 403 entitlement-
|
|
34
|
+
// failure response — Phase 3) short-circuits everything else: a still-
|
|
35
|
+
// validly-signed, still-unexpired token tells us nothing about
|
|
36
|
+
// revocation that happened after the last successful refresh()
|
|
37
|
+
// (ARCHITECTURE.md §7), so once refresh() has told us, that verdict
|
|
38
|
+
// wins over anything the cached token itself would otherwise report. No
|
|
39
|
+
// clock check, no watermark writes on this path — there's nothing left
|
|
40
|
+
// to protect once the installation itself is revoked.
|
|
41
|
+
const revoked = await storage.get('revoked');
|
|
42
|
+
if (revoked === 'true') {
|
|
43
|
+
return { status: 'revoked' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const now = getNow();
|
|
47
|
+
const storedLastValidatedAt = await storage.get('lastValidatedAt');
|
|
48
|
+
// storage.get() resolves null for a missing key — pass that through as
|
|
49
|
+
// null, NOT Number(null) (which is 0, a finite number). Coercing it
|
|
50
|
+
// would silently defeat assertNoClockRollback's fail-closed guard for
|
|
51
|
+
// "entitlementToken exists but no watermark has ever been written yet"
|
|
52
|
+
// (the state before Phase 3's activate() has run).
|
|
53
|
+
const lastValidatedAt = storedLastValidatedAt === null ? null : Number(storedLastValidatedAt);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
assertNoClockRollback({ now, lastValidatedAt });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
if (err instanceof TypeError || err instanceof ClockRollbackDetectedError) {
|
|
59
|
+
return { status: 'clock_rollback' };
|
|
60
|
+
}
|
|
61
|
+
throw err; // unexpected — propagate, never swallow into a fake status
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Ratchet the watermark forward now that the clock check has passed,
|
|
65
|
+
// regardless of what verification below decides — the observation "now
|
|
66
|
+
// is at least this far along" is true independent of this particular
|
|
67
|
+
// token's own validity. This closes the gap where a NAIVE clock
|
|
68
|
+
// rollback (rolling the clock back without also editing this stored
|
|
69
|
+
// value) would let an already-observed-expired token look valid again.
|
|
70
|
+
// It is NOT protection against an attacker who edits this file directly
|
|
71
|
+
// alongside the clock — that requires the same local filesystem write
|
|
72
|
+
// access the token file itself already assumes as the trust boundary
|
|
73
|
+
// (ARCHITECTURE.md §5); no purely local, secret-free value can defend
|
|
74
|
+
// against a party who can edit it directly. Accepted, documented
|
|
75
|
+
// limitation — see PROGRESS.md's Phase 2 entry, same shape as §7's
|
|
76
|
+
// revocation-propagation trade-off. Never reached on the rollback path
|
|
77
|
+
// above, so a detected (or ambiguous) rollback never regresses the
|
|
78
|
+
// stored watermark.
|
|
79
|
+
await storage.set('lastValidatedAt', String(now));
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const payload = await verifyEntitlementToken(tokenStr, { publicKeysByVersion, now });
|
|
83
|
+
|
|
84
|
+
// Reject a validly-signed token that belongs to a DIFFERENT
|
|
85
|
+
// installation: signature/expiry alone can't distinguish "issued to
|
|
86
|
+
// this device" from "issued to some other device, and copied here."
|
|
87
|
+
// installationId is written by activate() (Phase 3) from the same
|
|
88
|
+
// signed payload this check reads, so a mismatch here means either
|
|
89
|
+
// the stored value or the token was swapped independently of the
|
|
90
|
+
// other — collapsed into 'tampered' rather than a new status, same
|
|
91
|
+
// category as a signature-tampered token (ARCHITECTURE.md §9: don't
|
|
92
|
+
// invent a parallel vocabulary).
|
|
93
|
+
const storedInstallationId = await storage.get('installationId');
|
|
94
|
+
if (
|
|
95
|
+
storedInstallationId !== null &&
|
|
96
|
+
String(payload.installationId) !== storedInstallationId
|
|
97
|
+
) {
|
|
98
|
+
return { status: 'tampered' };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reject replay of a superseded (but still validly-signed, still
|
|
102
|
+
// unexpired) token: without this, restoring an old entitlementToken
|
|
103
|
+
// file — e.g. one saved before a downgrade — verifies cleanly and
|
|
104
|
+
// reports valid, since signature/expiry alone can't distinguish "the
|
|
105
|
+
// current token" from "a still-unexpired token this installation
|
|
106
|
+
// already moved past." Mirrors lastValidatedAt's watermark pattern,
|
|
107
|
+
// but keyed on the signature-authenticated payload.issuedAt rather
|
|
108
|
+
// than local clock time. Unlike lastValidatedAt, a missing watermark
|
|
109
|
+
// here is NOT fail-closed: the first token an installation ever
|
|
110
|
+
// verifies has nothing prior to compare against, so absence just
|
|
111
|
+
// means "nothing seen yet," not "something is wrong."
|
|
112
|
+
const storedHighestIssuedAt = await storage.get('highestIssuedAtSeen');
|
|
113
|
+
const highestIssuedAtSeen =
|
|
114
|
+
storedHighestIssuedAt === null ? null : Number(storedHighestIssuedAt);
|
|
115
|
+
if (highestIssuedAtSeen !== null && payload.issuedAt < highestIssuedAtSeen) {
|
|
116
|
+
return { status: 'tampered' };
|
|
117
|
+
}
|
|
118
|
+
if (highestIssuedAtSeen === null || payload.issuedAt > highestIssuedAtSeen) {
|
|
119
|
+
await storage.set('highestIssuedAtSeen', String(payload.issuedAt));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
status: 'valid',
|
|
124
|
+
expiresAt: payload.expiresAt,
|
|
125
|
+
featureIds: payload.featureIds,
|
|
126
|
+
features: payload.features,
|
|
127
|
+
};
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err instanceof TokenExpiredError) return { status: 'expired' };
|
|
130
|
+
if (err instanceof UnknownKeyVersionError) return { status: 'unknown_key_version' };
|
|
131
|
+
if (err instanceof TokenInvalidError) return { status: 'tampered' };
|
|
132
|
+
throw err; // unexpected — propagate, never swallow into a fake status
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { getEntitlement };
|
|
137
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Phase 4 — public API surface.
|
|
2
|
+
//
|
|
3
|
+
// Composes the four independent lifecycle factories (Phases 2-3) into one
|
|
4
|
+
// client, constructed once from a single shared config object, exposing
|
|
5
|
+
// exactly ARCHITECTURE.md §4's literal call shapes: activate(licenseKey),
|
|
6
|
+
// getEntitlement(), refresh(), deactivate(). Each sub-factory destructures
|
|
7
|
+
// only the config keys it needs, so the same object is safe to pass to all
|
|
8
|
+
// four (e.g. createDeactivateClient simply ignores publicKeys/getNow).
|
|
9
|
+
//
|
|
10
|
+
// storage defaults to a plain JSON-file adapter when omitted, continuing
|
|
11
|
+
// this project's already-settled "default storage is a plain JSON file"
|
|
12
|
+
// decision (ARCHITECTURE.md §5, CLAUDE.md) up to this composed-client level.
|
|
13
|
+
|
|
14
|
+
import { createActivateClient } from './activate.js';
|
|
15
|
+
import { createDeactivateClient } from './deactivate.js';
|
|
16
|
+
import { createEntitlementChecker } from './entitlement.js';
|
|
17
|
+
import { createRefreshClient } from './refresh.js';
|
|
18
|
+
import { createJsonFileAdapter } from './storage/json-file.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {object} options
|
|
22
|
+
* @param {import('./storage/adapter.js').StorageAdapter} [options.storage] - defaults to a JSON-file adapter at the default path
|
|
23
|
+
* @param {Record<string, string>} options.publicKeys - keyVersion -> PEM string
|
|
24
|
+
* @param {string} options.baseUrl - Keyforge base URL, e.g. 'https://licensing.example.com'
|
|
25
|
+
* @param {() => number} [options.getNow]
|
|
26
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
27
|
+
* @returns {Promise<{
|
|
28
|
+
* activate: (licenseKey: string) => Promise<{ expiresAt: number }>,
|
|
29
|
+
* getEntitlement: () => Promise<object>,
|
|
30
|
+
* refresh: () => Promise<object>,
|
|
31
|
+
* deactivate: () => Promise<void>,
|
|
32
|
+
* }>}
|
|
33
|
+
*/
|
|
34
|
+
export { createJsonFileAdapter };
|
|
35
|
+
|
|
36
|
+
export async function createKeyforgeClient({
|
|
37
|
+
storage = createJsonFileAdapter(),
|
|
38
|
+
publicKeys,
|
|
39
|
+
baseUrl,
|
|
40
|
+
getNow,
|
|
41
|
+
fetchImpl,
|
|
42
|
+
}) {
|
|
43
|
+
const sharedConfig = { storage, publicKeys, baseUrl, getNow, fetchImpl };
|
|
44
|
+
|
|
45
|
+
const [activateClient, entitlementChecker, refreshClient, deactivateClient] = await Promise.all([
|
|
46
|
+
createActivateClient(sharedConfig),
|
|
47
|
+
createEntitlementChecker(sharedConfig),
|
|
48
|
+
createRefreshClient(sharedConfig),
|
|
49
|
+
createDeactivateClient(sharedConfig),
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
activate: activateClient.activate,
|
|
54
|
+
getEntitlement: entitlementChecker.getEntitlement,
|
|
55
|
+
refresh: refreshClient.refresh,
|
|
56
|
+
deactivate: deactivateClient.deactivate,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Phase 3 — the one new error type for the network surface: a structured,
|
|
2
|
+
// non-2xx (and otherwise unhandled) response from the Keyforge API. Kept
|
|
3
|
+
// separate from src/crypto/errors.js's TokenVerificationError hierarchy —
|
|
4
|
+
// that hierarchy is about a token failing *local* verification; this one is
|
|
5
|
+
// about the *server* rejecting a request outright. Different failure domain,
|
|
6
|
+
// different vocabulary (mirrors ARCHITECTURE.md §9's "don't invent a
|
|
7
|
+
// parallel vocabulary" guidance by reusing the server's own `code` values
|
|
8
|
+
// verbatim rather than re-deriving new ones).
|
|
9
|
+
|
|
10
|
+
export class KeyforgeApiError extends Error {
|
|
11
|
+
/**
|
|
12
|
+
* @param {number} status - HTTP status code, or 0 if there was no response at all
|
|
13
|
+
* @param {string} code - server's `error.code`, or a synthetic one (e.g. `MALFORMED_RESPONSE`)
|
|
14
|
+
* @param {string} [message]
|
|
15
|
+
* @param {ErrorOptions} [options]
|
|
16
|
+
*/
|
|
17
|
+
constructor(status, code, message, options) {
|
|
18
|
+
super(message ?? `Keyforge API request failed (${code})`, options);
|
|
19
|
+
this.name = this.constructor.name;
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Phase 3 — small shared plumbing for the three network operations
|
|
2
|
+
// (activate/refresh/deactivate): identical POST-JSON request shape and
|
|
3
|
+
// identical `{ error: { code, message } }` envelope parsing, per
|
|
4
|
+
// docs/client-sdk-integration.md's error format. No HTTP client dependency:
|
|
5
|
+
// `fetchImpl` defaults to the global `fetch` (Node engine is already >=24).
|
|
6
|
+
|
|
7
|
+
import { KeyforgeApiError } from './errors.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} baseUrl
|
|
11
|
+
* @param {string} path
|
|
12
|
+
* @param {object} payload
|
|
13
|
+
* @param {typeof fetch} fetchImpl
|
|
14
|
+
*/
|
|
15
|
+
export function postJson(baseUrl, path, payload, fetchImpl) {
|
|
16
|
+
return fetchImpl(`${baseUrl}${path}`, {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
headers: { 'content-type': 'application/json' },
|
|
19
|
+
body: JSON.stringify(payload),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parses a success (2xx) response body, converting a missing/malformed JSON
|
|
25
|
+
* body into a KeyforgeApiError rather than letting a raw SyntaxError escape
|
|
26
|
+
* — a malicious/broken server sending e.g. `201` with an empty or non-JSON
|
|
27
|
+
* body should fail the same recognizable way as any other bad response.
|
|
28
|
+
* @param {Response} response
|
|
29
|
+
*/
|
|
30
|
+
export async function parseSuccessBody(response) {
|
|
31
|
+
try {
|
|
32
|
+
return await response.json();
|
|
33
|
+
} catch (err) {
|
|
34
|
+
throw new KeyforgeApiError(
|
|
35
|
+
response.status,
|
|
36
|
+
'MALFORMED_RESPONSE',
|
|
37
|
+
'Response body is not valid JSON',
|
|
38
|
+
{
|
|
39
|
+
cause: err,
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Builds a KeyforgeApiError from a non-success Response, tolerating a
|
|
47
|
+
* missing/malformed error body rather than throwing while handling an
|
|
48
|
+
* already-exceptional path.
|
|
49
|
+
* @param {Response} response
|
|
50
|
+
*/
|
|
51
|
+
export async function apiErrorFromResponse(response) {
|
|
52
|
+
let body = null;
|
|
53
|
+
try {
|
|
54
|
+
body = await response.json();
|
|
55
|
+
} catch {
|
|
56
|
+
// no/invalid JSON body — fall through to the synthetic code below
|
|
57
|
+
}
|
|
58
|
+
const code = body?.error?.code ?? 'MALFORMED_RESPONSE';
|
|
59
|
+
const message = body?.error?.message;
|
|
60
|
+
return new KeyforgeApiError(response.status, code, message);
|
|
61
|
+
}
|
package/src/refresh.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Phase 3 — POST /refresh, connectivity-gated, silent no-op offline.
|
|
2
|
+
//
|
|
3
|
+
// Closes Phase 2's other carry-forward item: gives refresh() a way to record
|
|
4
|
+
// a server-reported revocation so getEntitlement() can ever report
|
|
5
|
+
// 'revoked' (PROGRESS.md). Unlike activate()/deactivate(), this must never
|
|
6
|
+
// throw for "offline" or "try again later" (unreachable network, 429
|
|
7
|
+
// RATE_LIMITED) — those are expected, routine outcomes for a background
|
|
8
|
+
// operation. It DOES throw for genuinely unexpected server responses (e.g.
|
|
9
|
+
// 401 INSTALLATION_TOKEN_INVALID, 5xx, malformed body).
|
|
10
|
+
//
|
|
11
|
+
// The received entitlementToken is verified locally before being persisted,
|
|
12
|
+
// same MITM defense as activate() — a response arrives over the same
|
|
13
|
+
// network path and deserves the same signature gate before being trusted.
|
|
14
|
+
|
|
15
|
+
import { loadPublicKeys } from './crypto/keys.js';
|
|
16
|
+
import { verifyEntitlementToken } from './crypto/verify.js';
|
|
17
|
+
import { KeyforgeApiError } from './network/errors.js';
|
|
18
|
+
import { apiErrorFromResponse, parseSuccessBody, postJson } from './network/request.js';
|
|
19
|
+
|
|
20
|
+
const defaultGetNow = () => Math.floor(Date.now() / 1000);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} options
|
|
24
|
+
* @param {import('./storage/adapter.js').StorageAdapter} options.storage
|
|
25
|
+
* @param {Record<string, string>} options.publicKeys - keyVersion -> PEM string
|
|
26
|
+
* @param {string} options.baseUrl
|
|
27
|
+
* @param {() => number} [options.getNow]
|
|
28
|
+
* @param {typeof fetch} [options.fetchImpl]
|
|
29
|
+
* @returns {Promise<{ refresh: () => Promise<object> }>}
|
|
30
|
+
*/
|
|
31
|
+
export async function createRefreshClient({
|
|
32
|
+
storage,
|
|
33
|
+
publicKeys,
|
|
34
|
+
baseUrl,
|
|
35
|
+
getNow = defaultGetNow,
|
|
36
|
+
fetchImpl = fetch,
|
|
37
|
+
}) {
|
|
38
|
+
const publicKeysByVersion = await loadPublicKeys(publicKeys);
|
|
39
|
+
|
|
40
|
+
async function refresh() {
|
|
41
|
+
const installationToken = await storage.get('installationToken');
|
|
42
|
+
if (installationToken === null) {
|
|
43
|
+
return { status: 'not_activated' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let response;
|
|
47
|
+
try {
|
|
48
|
+
response = await postJson(
|
|
49
|
+
baseUrl,
|
|
50
|
+
'/api/v1/licenses/refresh',
|
|
51
|
+
{ installationToken },
|
|
52
|
+
fetchImpl,
|
|
53
|
+
);
|
|
54
|
+
} catch {
|
|
55
|
+
// Real connectivity check: attempt-and-catch, never a
|
|
56
|
+
// navigator.onLine-style flag. Silent no-op, no state change.
|
|
57
|
+
return { status: 'offline' };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (response.status === 429) {
|
|
61
|
+
// Same "try again later" bucket as unreachable — never throw for it.
|
|
62
|
+
return { status: 'offline' };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (response.status === 403) {
|
|
66
|
+
await storage.set('revoked', 'true');
|
|
67
|
+
return { status: 'revoked' };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (response.status !== 200) {
|
|
71
|
+
// Genuinely unexpected (401 INSTALLATION_TOKEN_INVALID, 400, 5xx, ...).
|
|
72
|
+
throw await apiErrorFromResponse(response);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const body = await parseSuccessBody(response);
|
|
76
|
+
const { entitlementToken } = body?.data ?? {};
|
|
77
|
+
if (typeof entitlementToken !== 'string') {
|
|
78
|
+
throw new KeyforgeApiError(
|
|
79
|
+
response.status,
|
|
80
|
+
'MALFORMED_RESPONSE',
|
|
81
|
+
'refresh() response is missing entitlementToken',
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const now = getNow();
|
|
86
|
+
const payload = await verifyEntitlementToken(entitlementToken, { publicKeysByVersion, now });
|
|
87
|
+
|
|
88
|
+
const storedInstallationId = await storage.get('installationId');
|
|
89
|
+
if (storedInstallationId !== null && String(payload.installationId) !== storedInstallationId) {
|
|
90
|
+
throw new KeyforgeApiError(
|
|
91
|
+
response.status,
|
|
92
|
+
'INSTALLATION_ID_MISMATCH',
|
|
93
|
+
'refresh() response entitlementToken belongs to a different installation',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Reject a stale or replayed response BEFORE persisting anything: a
|
|
98
|
+
// MITM (or malicious server) that captured a previously-accepted
|
|
99
|
+
// /refresh response must not be able to replay it later to roll
|
|
100
|
+
// entitlementToken back to an already-superseded token or — the
|
|
101
|
+
// critical case — to clear a `revoked` flag set by a real 403 that
|
|
102
|
+
// arrived after the captured response. Strictly-greater-than (not >=)
|
|
103
|
+
// rejects exact replays too: an honest refresh() always returns a token
|
|
104
|
+
// with a newer issuedAt than the last one this installation accepted.
|
|
105
|
+
const storedHighestIssuedAt = await storage.get('highestIssuedAtSeen');
|
|
106
|
+
const highestIssuedAtSeen =
|
|
107
|
+
storedHighestIssuedAt === null ? null : Number(storedHighestIssuedAt);
|
|
108
|
+
if (highestIssuedAtSeen !== null && payload.issuedAt <= highestIssuedAtSeen) {
|
|
109
|
+
throw new KeyforgeApiError(
|
|
110
|
+
response.status,
|
|
111
|
+
'STALE_TOKEN_REPLAY',
|
|
112
|
+
'refresh() response entitlementToken is not newer than the last one seen',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
await storage.set('entitlementToken', entitlementToken);
|
|
117
|
+
await storage.set('lastValidatedAt', String(now));
|
|
118
|
+
await storage.set('highestIssuedAtSeen', String(payload.issuedAt));
|
|
119
|
+
await storage.delete('revoked');
|
|
120
|
+
|
|
121
|
+
return { status: 'updated', expiresAt: payload.expiresAt };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { refresh };
|
|
125
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Phase 1 — storage interface: get(key)/set(key,value)/delete(key).
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {object} StorageAdapter
|
|
5
|
+
* @property {(key: string) => Promise<string | null>} get
|
|
6
|
+
* Resolves to the stored value, or `null` if `key` was never set. Never throws for a missing key.
|
|
7
|
+
* @property {(key: string, value: string) => Promise<void>} set
|
|
8
|
+
* Stores `value` under `key`, overwriting any existing value.
|
|
9
|
+
* @property {(key: string) => Promise<void>} delete
|
|
10
|
+
* Removes `key`. A no-op (does not throw) if `key` was never set.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Throws a TypeError unless `key` is a non-empty string. Shared by every
|
|
15
|
+
* StorageAdapter implementation so the key contract can't drift between them.
|
|
16
|
+
* @param {unknown} key
|
|
17
|
+
*/
|
|
18
|
+
export function assertValidKey(key) {
|
|
19
|
+
if (typeof key !== 'string' || key.length === 0) {
|
|
20
|
+
throw new TypeError(`StorageAdapter key must be a non-empty string, got: ${String(key)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Throws a TypeError unless `value` is a string. Shared by every
|
|
26
|
+
* StorageAdapter implementation so the value contract can't drift between them.
|
|
27
|
+
* @param {unknown} value
|
|
28
|
+
*/
|
|
29
|
+
export function assertValidValue(value) {
|
|
30
|
+
if (typeof value !== 'string') {
|
|
31
|
+
throw new TypeError(`StorageAdapter value must be a string, got: ${typeof value}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Phase 1 — default adapter, plain JSON file via node:fs.
|
|
2
|
+
|
|
3
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import { assertValidKey, assertValidValue } from './adapter.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default StorageAdapter (ARCHITECTURE.md §5): a single flat JSON file on
|
|
10
|
+
* disk, `{ [key]: value }`. Not one-file-per-key — the interface never asks
|
|
11
|
+
* for listing/iterating keys, so one file is simpler and sufficient.
|
|
12
|
+
*
|
|
13
|
+
* @param {object} [options]
|
|
14
|
+
* @param {string} [options.filePath] Defaults to `.keyforge-client/state.json`
|
|
15
|
+
* under the current working directory, mirroring Keyforge's own
|
|
16
|
+
* cwd-relative convention for local file state (see
|
|
17
|
+
* `scripts/generate-signing-keypair.js`'s `keys/` directory).
|
|
18
|
+
* @returns {import('./adapter.js').StorageAdapter}
|
|
19
|
+
*/
|
|
20
|
+
export function createJsonFileAdapter({ filePath } = {}) {
|
|
21
|
+
const resolvedPath = filePath ?? path.join(process.cwd(), '.keyforge-client', 'state.json');
|
|
22
|
+
const tmpPath = `${resolvedPath}.tmp`;
|
|
23
|
+
|
|
24
|
+
// Serializes every get/set/delete on this adapter instance so concurrent
|
|
25
|
+
// calls can't interleave a read-modify-write and lose an update.
|
|
26
|
+
let queue = Promise.resolve();
|
|
27
|
+
function enqueue(operation) {
|
|
28
|
+
const result = queue.then(operation);
|
|
29
|
+
// Keep the chain alive even if this operation rejects, so later queued
|
|
30
|
+
// operations still run instead of hanging behind a rejected promise.
|
|
31
|
+
queue = result.catch(() => {});
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function readStore() {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(await readFile(resolvedPath, 'utf8'));
|
|
38
|
+
} catch (err) {
|
|
39
|
+
if (err.code === 'ENOENT') return {};
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function writeStore(store) {
|
|
45
|
+
await mkdir(path.dirname(resolvedPath), { recursive: true, mode: 0o700 });
|
|
46
|
+
await writeFile(tmpPath, JSON.stringify(store, null, 2), { mode: 0o600 });
|
|
47
|
+
await rename(tmpPath, resolvedPath);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async get(key) {
|
|
52
|
+
assertValidKey(key);
|
|
53
|
+
return enqueue(async () => {
|
|
54
|
+
const store = await readStore();
|
|
55
|
+
return store[key] ?? null;
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
async set(key, value) {
|
|
60
|
+
assertValidKey(key);
|
|
61
|
+
assertValidValue(value);
|
|
62
|
+
return enqueue(async () => {
|
|
63
|
+
const store = await readStore();
|
|
64
|
+
store[key] = value;
|
|
65
|
+
await writeStore(store);
|
|
66
|
+
});
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
async delete(key) {
|
|
70
|
+
assertValidKey(key);
|
|
71
|
+
return enqueue(async () => {
|
|
72
|
+
const store = await readStore();
|
|
73
|
+
delete store[key];
|
|
74
|
+
await writeStore(store);
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|