Skip to content

Commit af0df3f

Browse files
authored
fix: harden crypto input validation (#30)
1 parent 549ad5f commit af0df3f

7 files changed

Lines changed: 136 additions & 30 deletions

File tree

.vscode/settings.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,5 +35,5 @@
3535
// File explorer improvements
3636
"explorer.sortOrder": "type",
3737

38-
"cSpell.words": ["firestore", "kriasoft", "syncguard", "vitepress"]
38+
"cSpell.words": ["csprng", "firestore", "kriasoft", "syncguard", "vitepress"]
3939
}

README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,25 @@
77

88
TypeScript distributed lock library that prevents race conditions across services. Supports Redis, PostgreSQL, and Firestore backends with automatic cleanup, fencing tokens, and bulletproof concurrency control.
99

10+
## Documentation
11+
12+
- **Docs site:** https://kriasoft.com/syncguard/
13+
- **Backend guides:** [Redis](./redis/README.md) · [PostgreSQL](./postgres/README.md) · [Firestore](./firestore/README.md)
14+
1015
## Requirements
1116

1217
- **Node.js** ≥20.0.0 (targets AsyncDisposable/`await using`; older runtimes require try/finally plus a polyfill, but official support is 20+)
1318

19+
## Compatibility
20+
21+
| Runtime / Backend | Support |
22+
| ------------------ | --------------------------------------------------------------------------- |
23+
| Node.js | 20+ (native AsyncDisposable/`await using`) |
24+
| Bun | 1.0+ (used for `bun test`) |
25+
| Redis backend | Redis 6+ with `ioredis` ^5 peer dependency |
26+
| PostgreSQL backend | PostgreSQL 12+ with `postgres` ^3 peer dependency |
27+
| Firestore backend | `@google-cloud/firestore` ^8 peer dependency (emulator supported for tests) |
28+
1429
## Installation
1530

1631
SyncGuard is backend-agnostic. Install the base package plus any backends you need:
@@ -20,9 +35,9 @@ SyncGuard is backend-agnostic. Install the base package plus any backends you ne
2035
npm install syncguard
2136

2237
# Choose one or more backends (optional peer dependencies):
23-
npm install ioredis # for Redis backend
24-
npm install postgres # for PostgreSQL backend
25-
npm install @google-cloud/firestore # for Firestore backend
38+
npm install syncguard ioredis # Redis backend
39+
npm install syncguard postgres # PostgreSQL backend
40+
npm install syncguard @google-cloud/firestore # Firestore backend
2641
```
2742

2843
Only install the backend packages you actually use. If you attempt to use a backend without its package installed, you'll get a clear error message.
@@ -92,6 +107,8 @@ await lock(
92107

93108
### Manual Lock Control with Automatic Cleanup
94109

110+
Node.js 20+ supports `await using` natively; for older runtimes, drop to try/finally (see below).
111+
95112
Use `await using` for automatic cleanup on all code paths (Node.js ≥20):
96113

97114
```typescript
@@ -572,6 +589,13 @@ acquisition: {
572589
}
573590
```
574591

592+
## Development
593+
594+
- `bun test test/unit` — fast unit tests
595+
- `bun test test/contracts test/e2e` — contracts + e2e suite
596+
- `npm run build` — type-check and emit `dist/`
597+
- `npm run redis` / `npm run firestore` — spin up local Redis or Firestore emulator for tests
598+
575599
## Contributing
576600

577601
We welcome contributions! Here's how you can help:

bun.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

common/constants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,10 @@ export const FENCE_THRESHOLDS = {
153153
*/
154154
WARN: "090000000000000",
155155
} as const;
156+
157+
/**
158+
* Maximum value that can be formatted as a 15-digit fence token.
159+
* This is the format limit (10^15 - 1), distinct from FENCE_THRESHOLDS.MAX
160+
* which is the operational limit enforced by backends.
161+
*/
162+
export const FENCE_FORMAT_MAX = 999_999_999_999_999n;

common/crypto.ts

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// SPDX-License-Identifier: MIT
33

44
import { createHash, randomBytes } from "node:crypto";
5+
import { FENCE_FORMAT_MAX } from "./constants.js";
56
import { LockError } from "./errors.js";
67
import type { HashId } from "./types.js";
78

@@ -35,12 +36,11 @@ export function generateLockId(): string {
3536

3637
/**
3738
* Canonical 96-bit hash for user keys (NFC normalized, 24 hex chars).
38-
* Collision probability: ~6.3e-12 at 10^9 distinct IDs.
3939
*
4040
* @remarks **Non-cryptographic hash for observability only.**
41-
* This function uses a fast, non-cryptographic hash algorithm suitable for
42-
* sanitization, telemetry, and UI display. Do NOT use for security-sensitive
43-
* collision resistance or any cryptographic purposes.
41+
* Uses a fast triple-hash algorithm suitable for sanitization, telemetry,
42+
* and UI display. Effective 96-bit space provides low collision probability
43+
* for typical workloads. Do NOT use for security-sensitive purposes.
4444
*
4545
* @param value - User-provided key string
4646
* @returns 24-character hex hash identifier
@@ -72,20 +72,28 @@ export function hashKey(value: string): HashId {
7272
* Internal helper - backends use this for consistent fence formatting.
7373
* 15-digit format guarantees full safety within Lua's 53-bit precision limit
7474
* (2^53-1 ≈ 9.007e15) while providing 10^15 capacity (~31.7 years at 1M locks/sec).
75-
* @param value - Fence counter (bigint or number)
75+
* @param value - Fence counter (bigint or integer number)
7676
* @returns 15-digit string (e.g., "000000000000001")
77-
* @throws {LockError} "InvalidArgument" if value is negative or exceeds 15-digit limit
77+
* @throws {LockError} "InvalidArgument" if value is not a finite non-negative integer
7878
*/
7979
export function formatFence(value: bigint | number): string {
80-
// Convert to bigint and enforce integer + range
81-
const n = typeof value === "number" ? BigInt(Math.trunc(value)) : value;
80+
// Validate numbers before BigInt conversion to avoid leaking RangeError
81+
if (typeof value === "number") {
82+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
83+
throw new LockError(
84+
"InvalidArgument",
85+
"Fence must be a finite non-negative integer",
86+
);
87+
}
88+
}
89+
90+
const n = typeof value === "bigint" ? value : BigInt(value);
8291

8392
if (n < 0n) {
8493
throw new LockError("InvalidArgument", "Fence must be non-negative");
8594
}
8695

87-
if (n > 999_999_999_999_999n) {
88-
// 15 digits max (10^15 - 1)
96+
if (n > FENCE_FORMAT_MAX) {
8997
throw new LockError(
9098
"InvalidArgument",
9199
`Fence exceeds 15-digit limit: ${n}`,
@@ -144,6 +152,29 @@ export function makeStorageKey(
144152
backendLimitBytes: number,
145153
reserveBytes: number,
146154
): string {
155+
// Validate configuration (fail fast on misconfigured backends)
156+
if (
157+
!Number.isFinite(backendLimitBytes) ||
158+
!Number.isInteger(backendLimitBytes) ||
159+
backendLimitBytes <= 0
160+
) {
161+
throw new LockError(
162+
"InvalidArgument",
163+
"backendLimitBytes must be a positive integer",
164+
);
165+
}
166+
167+
if (
168+
!Number.isFinite(reserveBytes) ||
169+
!Number.isInteger(reserveBytes) ||
170+
reserveBytes < 0
171+
) {
172+
throw new LockError(
173+
"InvalidArgument",
174+
"reserveBytes must be a non-negative integer",
175+
);
176+
}
177+
147178
// Validate key is not empty
148179
if (!key) {
149180
throw new LockError("InvalidArgument", "Key must not be empty");

package.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "syncguard",
3-
"version": "2.5.2",
3+
"version": "2.5.3",
44
"description": "Functional TypeScript library for distributed locking across microservices. Prevents race conditions with Redis, PostgreSQL, Firestore, and custom backends. Features automatic lock management, timeout handling, and extensible architecture.",
55
"keywords": [
66
"atomic",
@@ -91,14 +91,14 @@
9191
},
9292
"devDependencies": {
9393
"@google-cloud/firestore": "^8.0.0",
94-
"@types/bun": "1.3.3",
95-
"firebase-tools": "^14.26.0",
94+
"@types/bun": "1.3.4",
95+
"firebase-tools": "^14.27.0",
9696
"gh-pages": "^6.3.0",
9797
"husky": "^9.1.7",
9898
"ioredis": "^5.8.1",
9999
"lint-staged": "^16.2.7",
100100
"postgres": "^3.4.7",
101-
"prettier": "^3.7.3",
101+
"prettier": "^3.7.4",
102102
"prettier-plugin-sql": "^0.19.2",
103103
"typescript": "^5.9.3",
104104
"vitepress": "^2.0.0-alpha.15"

test/unit/common/crypto.test.ts

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -149,9 +149,34 @@ describe("makeStorageKey", () => {
149149
it("should throw when prefix exceeds backend limit", () => {
150150
const longPrefix = "x".repeat(500);
151151
expect(() => makeStorageKey(longPrefix, "key", 100, 0)).toThrow(
152-
"Prefix exceeds backend limit",
152+
/Prefix exceeds backend limit/,
153153
);
154154
});
155+
156+
it("should reject invalid backendLimitBytes", () => {
157+
expect(() => makeStorageKey("p", "k", -1, 0)).toThrow(LockError);
158+
expect(() => makeStorageKey("p", "k", 0, 0)).toThrow(LockError);
159+
expect(() => makeStorageKey("p", "k", NaN, 0)).toThrow(LockError);
160+
expect(() => makeStorageKey("p", "k", Infinity, 0)).toThrow(LockError);
161+
expect(() => makeStorageKey("p", "k", 1000.5, 0)).toThrow(LockError);
162+
expect(() => makeStorageKey("p", "k", -1, 0)).toThrow(
163+
"backendLimitBytes must be a positive integer",
164+
);
165+
});
166+
167+
it("should reject invalid reserveBytes", () => {
168+
expect(() => makeStorageKey("p", "k", 1000, -1)).toThrow(LockError);
169+
expect(() => makeStorageKey("p", "k", 1000, NaN)).toThrow(LockError);
170+
expect(() => makeStorageKey("p", "k", 1000, Infinity)).toThrow(LockError);
171+
expect(() => makeStorageKey("p", "k", 1000, 0.5)).toThrow(LockError);
172+
expect(() => makeStorageKey("p", "k", 1000, -1)).toThrow(
173+
"reserveBytes must be a non-negative integer",
174+
);
175+
});
176+
177+
it("should allow zero reserveBytes", () => {
178+
expect(() => makeStorageKey("p", "k", 1000, 0)).not.toThrow();
179+
});
155180
});
156181

157182
describe("hashKey", () => {
@@ -245,14 +270,33 @@ describe("formatFence", () => {
245270
expect(formatFence(999_999_999_999_999n)).toBe("999999999999999");
246271
});
247272

248-
it("should truncate floating point numbers", () => {
249-
expect(formatFence(42.9)).toBe("000000000000042");
250-
expect(formatFence(42.1)).toBe("000000000000042");
273+
it("should accept values at operational threshold (FENCE_THRESHOLDS.MAX)", () => {
274+
// Sanity check: operational threshold (9e14) < format limit (10^15-1)
275+
expect(formatFence(900_000_000_000_000)).toBe("900000000000000");
276+
expect(formatFence(900_000_000_000_000n)).toBe("900000000000000");
277+
});
278+
279+
it("should reject non-integer numbers", () => {
280+
expect(() => formatFence(42.9)).toThrow(LockError);
281+
expect(() => formatFence(42.1)).toThrow(LockError);
282+
expect(() => formatFence(0.1)).toThrow(LockError);
283+
});
284+
285+
it("should reject negative fractional values", () => {
286+
// Edge case: -0.1 must not be truncated to 0
287+
expect(() => formatFence(-0.1)).toThrow(LockError);
288+
expect(() => formatFence(-0.0001)).toThrow(LockError);
289+
});
290+
291+
it("should reject non-finite values", () => {
292+
expect(() => formatFence(NaN)).toThrow(LockError);
293+
expect(() => formatFence(Infinity)).toThrow(LockError);
294+
expect(() => formatFence(-Infinity)).toThrow(LockError);
251295
});
252296

253297
it("should throw for negative values", () => {
254298
expect(() => formatFence(-1)).toThrow(LockError);
255-
expect(() => formatFence(-1)).toThrow("Fence must be non-negative");
299+
expect(() => formatFence(-1)).toThrow("finite non-negative integer");
256300
expect(() => formatFence(-1n)).toThrow("Fence must be non-negative");
257301
});
258302

0 commit comments

Comments
 (0)