Skip to content

Commit 3e52943

Browse files
committed
fix(verify): validation security and API naming 🐛
- Reject non-integer secret in verify - Rename getHash to generate in API, docs, and tests - Enforce 0..1e10 range and strict equality in verify (fix 32-bit false accept) - Update README and USAGE for default export and generate flow - Update deno.json and package.json description and keywords
1 parent 9f6ac84 commit 3e52943

10 files changed

Lines changed: 137 additions & 108 deletions

File tree

README.md

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,26 +37,28 @@ deno add jsr:@neabyte/trustless-id
3737

3838
## Quick Start
3939

40-
Create an instance with a **connector ID** (e.g. service URL or app identifier). Generate a one-time **hashId**, build a **requestId**, then decode to a numeric code or verify with a user secret.
40+
Use a **connector ID** (e.g. service URL or app identifier) on both sides. Generate a one-time **hashId**, create an **instance**, build a **requestId**, then decode to a numeric code or verify with a user secret.
4141

4242
```typescript
43-
import Trustless from '@neabyte/trustless-id'
43+
import trustless from '@neabyte/trustless-id'
4444

4545
// Connector ID (same on both sides)
4646
const connectorId = 'trustless://auth/example.com:0.1.0?service=none'
47-
const instance = Trustless.create(connectorId)
4847

4948
// One-time hashId per session
50-
const hashId = Trustless.getHash(connectorId)
49+
const hashId = trustless.generate(connectorId)
5150

52-
// Request with 10s expiry window
51+
// Instance for connector (same on client and verifier)
52+
const instance = trustless.create(connectorId)
53+
54+
// Build payload; send requestId to verifier (QR / link / form)
5355
const requestId = instance.request(hashId, 10)
5456

55-
// Decode to code, or null if invalid/expired
57+
// Verifier decodes to get code
5658
const codeId = instance.decode(hashId, requestId)
5759
if (codeId !== null) {
5860
console.log('Code:', codeId)
59-
// Verify user secret
61+
// Check user secret
6062
console.log('Verify:', instance.verify(requestId, codeId))
6163
}
6264
```

USAGE.md

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,21 @@ API, flow, options — request, decode, verify time-bound IDs.
1616

1717
## Quick Start
1818

19-
**Flow:** `connectorId`instance`hashId` → request → decode → verify.
19+
**Flow:** `connectorId`hashIdinstance → request → decode → verify.
2020

2121
```typescript
22-
import Trustless from '@neabyte/trustless-id'
22+
import trustless from '@neabyte/trustless-id'
2323

2424
// Connector ID (same on both sides)
2525
const connectorId = 'trustless://auth/example.com:0.1.0?service=none'
26-
const instance = Trustless.create(connectorId)
2726

2827
// One-time hashId per session
29-
const hashId = Trustless.getHash(connectorId)
28+
const hashId = trustless.generate(connectorId)
3029

31-
// Request with 10s expiry window
30+
// Instance for connector (same on client and verifier)
31+
const instance = trustless.create(connectorId)
32+
33+
// Request with 10s expiry window; send requestId to verifier (QR / link / form)
3234
const requestId = instance.request(hashId, 10)
3335

3436
// Decode to code, or null if invalid/expired
@@ -41,66 +43,67 @@ if (codeId !== null) {
4143

4244
## Flow Overview
4345

44-
1. **Connector** — Both client and verifier use the same `connectorId` (e.g. service URL). Each creates `Trustless.create(connectorId)`.
45-
2. **Hash** — Client calls `Trustless.getHash(connectorId)` once per session; returns a 197-char hex `hashId` (unique per call).
46-
3. **Request** — Client calls `instance.request(hashId, expireTime?)` to get `requestId` (encoded payload with time slot and window). Send `requestId` (e.g. QR, link, form).
47-
4. **Decode** — Verifier has `hashId` (e.g. stored or passed with the request). Verifier calls `instance.decode(hashId, requestId)` to get numeric `codeId`, or `null` if invalid/expired.
48-
5. **Verify** — User enters the code (or it’s shown). Verifier calls `instance.verify(requestId, secret)` with that value; returns `true` when not expired and code matches.
46+
1. **Connector** — Both client and verifier use the same `connectorId` (e.g. service URL).
47+
2. **Hash** — Client calls `trustless.generate(connectorId)` once per session; returns a 197-char hex `hashId` (unique per call). This is the session identifier.
48+
3. **Instance** — Both sides call `trustless.create(connectorId)` to get an instance bound to that connector.
49+
4. **Request** — Client calls `instance.request(hashId, expireTime?)` to get `requestId`. Send `requestId` and `hashId` to the verifier (e.g. QR, link, form).
50+
5. **Decode** — Verifier calls `instance.decode(hashId, requestId)` to get numeric `codeId`, or `null` if invalid/expired. Verifier must use the same `hashId` received from the client.
51+
6. **Verify** — User enters the code (or it’s shown). Verifier calls `instance.verify(requestId, secret)`; returns `true` when not expired and code matches.
4952

5053
## Methods Overview
5154

5255
| Method | Type | Returns | Description |
5356
| :--------------------------------------------------------------------------- | :------- | :--------------- | :------------------------------------------------ |
54-
| [`Trustless.create(connectorId)`](#trustlesscreateconnectorid) | static | `Trustless` | Factory: new instance bound to connector. |
55-
| [`Trustless.getHash(connectorId)`](#trustlessgethashconnectorid) | static | `HashId` | One-time 197-char hex hash. |
57+
| [`trustless.create(connectorId)`](#trustlesscreateconnectorid) | static | instance | Factory: new instance bound to connector. |
58+
| [`trustless.generate(connectorId)`](#trustlessgenerateconnectorid) | static | `HashId` | One-time 197-char hex hash. |
5659
| [`instance.request(hashId, expireTime?)`](#instancerequesthashid-expiretime) | instance | `RequestId` | Encoded payload string or `''` if hashId invalid. |
5760
| [`instance.decode(hashId, requestId)`](#instancedecodehashid-requestid) | instance | `CodeId \| null` | Numeric code when valid and not expired. |
5861
| [`instance.verify(requestId, secret)`](#instanceverifyrequestid-secret) | instance | `boolean` | True when not expired and secret matches code. |
5962

6063
## ConnectorId and HashId
6164

6265
- **ConnectorId** — Any non-secret string that identifies the connector (e.g. `trustless://auth/example.com:0.1.0?service=none`). Trimmed before hashing. Same value on both sides yields the same encryption key.
63-
- **HashId** — 197 lowercase hex chars from `Trustless.getHash(connectorId)`. Includes timestamp and random nonce; different on every call. Must be passed to `request` and (on verifier side) to `decode` for the same session.
66+
- **HashId** — 197 lowercase hex chars from `trustless.generate(connectorId)`. Includes timestamp and random nonce; different on every call. Client must pass `hashId` together with `requestId` to the verifier so the verifier can call `decode(hashId, requestId)`.
6467

65-
```typescript
66-
const hashId = Trustless.getHash(connectorId)
67-
// hashId.length === 197, /^[0-9a-f]{197}$/.test(hashId) === true
68-
```
68+
```typescript
69+
const hashId = trustless.generate(connectorId)
70+
// hashId.length === 197, /^[0-9a-f]{197}$/.test(hashId) === true
71+
```
6972

7073
## Request and Expiry
7174

7275
- **request(hashId, expireTime?)** — Validates `hashId` (197 hex chars), then encodes `hashId` + current time slot + window. Returns fixed-length `RequestId` or `''` if `hashId` invalid.
7376
- **expireTime** — Window in seconds (1–60). Default 10. Clamped via `Cipher.clampWindow`. Same `hashId` and same window in the same time slot yields the same `requestId` and same `codeId`.
7477

75-
```typescript
76-
const requestId = instance.request(hashId, 10)
77-
const requestIdLong = instance.request(hashId, 60)
78-
// requestId length: 6 (slot) + 2 (window) + 197 (hashId) = 205
79-
```
78+
```typescript
79+
const requestId = instance.request(hashId, 10)
80+
const requestIdLong = instance.request(hashId, 60)
81+
// requestId length: 6 (slot) + 2 (window) + 197 (hashId) = 205
82+
```
8083

8184
## Decode and Verify
8285

8386
- **decode(hashId, requestId)** — Decodes with instance key; checks expiry; ensures payload `hashId` matches argument; returns derived numeric code or `null`. Code is in range `0``1e10`.
8487
- **verify(requestId, secret)** — Decodes, checks expiry, derives expected code, compares to `secret`. `secret` can be number or string (digits only); returns `true` when match and not expired.
8588

86-
```typescript
87-
const codeId = instance.decode(hashId, requestId)
88-
if (codeId !== null) {
89-
instance.verify(requestId, codeId) === true
90-
instance.verify(requestId, String(codeId)) === true
91-
}
92-
```
89+
```typescript
90+
const codeId = instance.decode(hashId, requestId)
91+
if (codeId !== null) {
92+
instance.verify(requestId, codeId) === true
93+
instance.verify(requestId, String(codeId)) === true
94+
}
95+
```
9396

9497
## API Reference
9598

96-
### Trustless.create(connectorId)
99+
### trustless.create(connectorId)
97100

98101
Create an instance bound to the given connector. The instance key is a hash of the trimmed `connectorId`.
99102

100103
- `connectorId` `<ConnectorId>`: Caller identifier (string, trimmed).
101-
- Returns: `<Trustless>` New instance.
104+
- Returns: New instance (use for `request`, `decode`, `verify`).
102105

103-
### Trustless.getHash(connectorId)
106+
### trustless.generate(connectorId)
104107

105108
Generate a one-time 197-char hex hash. Uses connectorId + timestamp + random nonce. Call once per session; each call returns a different value.
106109

@@ -111,7 +114,7 @@ Generate a one-time 197-char hex hash. Uses connectorId + timestamp + random non
111114

112115
Build the encoded request payload for the given hash and optional expiry window.
113116

114-
- `hashId` `<HashId>`: 197-char hex from `getHash`.
117+
- `hashId` `<HashId>`: 197-char hex from `generate`.
115118
- `expireTime` `<ExpireTime | undefined>`: Window in seconds (1–60). Default 10.
116119
- Returns: `<RequestId>` Encoded string of length 205, or `''` if `hashId` invalid.
117120

@@ -136,7 +139,7 @@ Check that the user-provided secret matches the derived code and the payload is
136139
Types are exported for TypeScript: `import type { CodeId, ConnectorId, DecodedPayload, ExpireTime, HashId, RequestId, VerifySecret } from '@neabyte/trustless-id'`.
137140

138141
- **ConnectorId:** `<string>` — Caller identifier.
139-
- **HashId:** `<string>` — 197-char hex from `getHash`.
142+
- **HashId:** `<string>` — 197-char hex from `generate`.
140143
- **RequestId:** `<string>` — Encoded payload from `request`.
141144
- **CodeId:** `<number>` — Numeric code from `decode` (0–1e10).
142145
- **ExpireTime:** `<number>` — Window in seconds (1–60).

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@neabyte/trustless-id",
3-
"description": "Trustless ID",
3+
"description": "Time-bound anonymous ID: request, decode, verify. No server",
44
"version": "0.1.0",
55
"type": "module",
66
"license": "MIT",

package.json

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@neabyte/trustless-id",
33
"version": "0.1.0",
4-
"description": "Trustless ID",
4+
"description": "Time-bound anonymous ID: request, decode, verify. No server",
55
"type": "module",
66
"main": "dist/index.cjs",
77
"module": "dist/index.mjs",
@@ -19,8 +19,25 @@
1919
"prepublishOnly": "rm -rf dist && npm run build"
2020
},
2121
"keywords": [
22+
"anonymous-id",
23+
"client-side",
24+
"connector",
25+
"deno",
26+
"esm",
27+
"handshake",
28+
"javascript",
29+
"library",
30+
"no-server",
31+
"nodejs",
32+
"one-time-code",
33+
"pairing",
34+
"session-id",
35+
"time-bound",
2236
"trustless",
23-
"anonymous-id"
37+
"typescript",
38+
"verification",
39+
"verification-code",
40+
"zero-dependency"
2441
],
2542
"dependencies": {},
2643
"devDependencies": {

src/Types.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
/** Connector identifier from caller. */
22
export type ConnectorId = string
3-
/** 197-char hex hash from getHash. */
3+
4+
/** 197-char hex hash from generate. */
45
export type HashId = string
6+
57
/** Encoded payload string from request. */
68
export type RequestId = string
9+
710
/** Numeric code from decode for verification. */
811
export type CodeId = number
12+
913
/** Expiry window in seconds (1–60). */
1014
export type ExpireTime = number
1115

src/index.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export default class Trustless {
3434
* @param connectorId - Caller identifier
3535
* @returns 197-char hex HashId
3636
*/
37-
static getHash(connectorId: Types.ConnectorId): Types.HashId {
37+
static generate(connectorId: Types.ConnectorId): Types.HashId {
3838
const nonce = String(Date.now()) + String(Math.random())
3939
return Cipher.generateHash(String(connectorId).trim() + nonce)
4040
}
@@ -63,7 +63,7 @@ export default class Trustless {
6363
/**
6464
* Build requestId for hashId and optional window.
6565
* @description Validates hashId then encodes with current slot and window.
66-
* @param hashId - 197-char hex from getHash
66+
* @param hashId - 197-char hex from generate
6767
* @param expireTime - Optional window seconds (default 10)
6868
* @returns RequestId string or empty when hashId invalid
6969
*/
@@ -98,10 +98,13 @@ export default class Trustless {
9898
const actualCode = typeof secret === 'number'
9999
? secret
100100
: parseInt(String(secret).replace(/\D/g, ''), 10)
101-
if (Number.isNaN(actualCode)) {
101+
if (Number.isNaN(actualCode) || !Number.isInteger(actualCode)) {
102102
return false
103103
}
104-
return expectedCode >>> 0 === actualCode >>> 0
104+
if (actualCode < 0 || actualCode > 1e10) {
105+
return false
106+
}
107+
return expectedCode === actualCode
105108
}
106109
}
107110

tests/EdgeCases.test.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Deno.test('EdgeCases - connectorId with whitespace is trimmed', () => {
1818
const connectorId = makeConnectorId('example.com')
1919
const withSpaces = ' ' + connectorId + ' '
2020
const instance = Trustless.create(withSpaces)
21-
const hashId = Trustless.getHash(connectorId)
21+
const hashId = Trustless.generate(connectorId)
2222
const requestId = instance.request(hashId, 10)
2323
const codeId = instance.decode(hashId, requestId)
2424
assertEquals(codeId !== null, true)
@@ -28,7 +28,7 @@ Deno.test('EdgeCases - connectorId with whitespace is trimmed', () => {
2828
Deno.test('EdgeCases - decode with empty hashId string still validates by hashId match', () => {
2929
const connectorId = makeConnectorId('example.com')
3030
const instance = Trustless.create(connectorId)
31-
const hashId = Trustless.getHash(connectorId)
31+
const hashId = Trustless.generate(connectorId)
3232
const requestId = instance.request(hashId, 10)
3333
const codeId = instance.decode('', requestId)
3434
assertEquals(codeId, null)
@@ -49,7 +49,7 @@ Deno.test('EdgeCases - isExpired with invalid requestId returns true', () => {
4949
Deno.test('EdgeCases - request clamps expireTime to valid range', () => {
5050
const connectorId = makeConnectorId('example.com')
5151
const instance = Trustless.create(connectorId)
52-
const hashId = Trustless.getHash(connectorId)
52+
const hashId = Trustless.generate(connectorId)
5353
const requestIdZero = instance.request(hashId, 0)
5454
const requestIdHuge = instance.request(hashId, 999)
5555
assert(requestIdZero.length > 0)
@@ -80,7 +80,7 @@ Deno.test(
8080
() => {
8181
const connectorId = makeConnectorId('example.com')
8282
const instance = Trustless.create(connectorId)
83-
const hashId = Trustless.getHash(connectorId)
83+
const hashId = Trustless.generate(connectorId)
8484
const requestId = instance.request(hashId, Number.NaN)
8585
assertEquals(requestId.length, 205)
8686
const codeId = instance.decode(hashId, requestId)
@@ -94,7 +94,7 @@ Deno.test(
9494
() => {
9595
const connectorId = makeConnectorId('example.com')
9696
const instance = Trustless.create(connectorId)
97-
const hashId = Trustless.getHash(connectorId)
97+
const hashId = Trustless.generate(connectorId)
9898
const requestId = instance.request(hashId, -5)
9999
assertEquals(requestId.length, 205)
100100
const codeId = instance.decode(hashId, requestId)
@@ -105,15 +105,15 @@ Deno.test(
105105
Deno.test('EdgeCases - verify NaN secret returns false', () => {
106106
const connectorId = makeConnectorId('example.com')
107107
const instance = Trustless.create(connectorId)
108-
const hashId = Trustless.getHash(connectorId)
108+
const hashId = Trustless.generate(connectorId)
109109
const requestId = instance.request(hashId, 10)
110110
assertEquals(instance.verify(requestId, Number.NaN), false)
111111
})
112112

113113
Deno.test('EdgeCases - verify with string leading zeros strips and validates', () => {
114114
const connectorId = makeConnectorId('example.com')
115115
const instance = Trustless.create(connectorId)
116-
const hashId = Trustless.getHash(connectorId)
116+
const hashId = Trustless.generate(connectorId)
117117
const requestId = instance.request(hashId, 10)
118118
const codeId = instance.decode(hashId, requestId)
119119
assert(codeId !== null)
@@ -124,7 +124,7 @@ Deno.test('EdgeCases - verify with string leading zeros strips and validates', (
124124
Deno.test('EdgeCases - verify with string non-numeric secret returns false', () => {
125125
const connectorId = makeConnectorId('example.com')
126126
const instance = Trustless.create(connectorId)
127-
const hashId = Trustless.getHash(connectorId)
127+
const hashId = Trustless.generate(connectorId)
128128
const requestId = instance.request(hashId, 10)
129129
assertEquals(instance.verify(requestId, 'no-digits'), false)
130130
assertEquals(instance.verify(requestId, ''), false)
@@ -133,7 +133,7 @@ Deno.test('EdgeCases - verify with string non-numeric secret returns false', ()
133133
Deno.test('EdgeCases - verify with string secret strips non-digits and validates', () => {
134134
const connectorId = makeConnectorId('example.com')
135135
const instance = Trustless.create(connectorId)
136-
const hashId = Trustless.getHash(connectorId)
136+
const hashId = Trustless.generate(connectorId)
137137
const requestId = instance.request(hashId, 10)
138138
const codeId = instance.decode(hashId, requestId)
139139
assert(codeId !== null)

0 commit comments

Comments
 (0)