Skip to content

Commit de3b203

Browse files
committed
feat(auth)!: 6-digit OTP pairing code and crypto-secure tokens
Separate the two roles the auth flow previously conflated. The persistent client bearer token and remote-dock tokens become high-entropy, CSPRNG credentials, while the human-typed pairing code becomes an easy-to-enter 6-digit one-time code guarded by a short TTL and an attempt cap. The pairing code is single-use, expires after five minutes, is compared in constant time, and rotates after five failed attempts, so its shorter length stays brute-force resistant. Bearer and dock tokens move off the vendored word-list generator (which relied on Math.random) to WebCrypto-backed randomness. The vendored human-id helper is removed in favour of a new `devframe/utils/crypto-token` util exposing `randomToken`, `randomDigits`, and `timingSafeEqual`. BREAKING CHANGE: the `devframe/utils/human-id` export is removed. Use `randomToken` from `devframe/utils/crypto-token` for random identifiers.
1 parent 911119b commit de3b203

16 files changed

Lines changed: 145 additions & 60 deletions

File tree

alias.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ export const alias = {
1818
'devframe/node': r('devframe/src/node/index.ts'),
1919
'devframe/constants': r('devframe/src/constants.ts'),
2020
'devframe/utils/colors': r('devframe/src/utils/colors.ts'),
21+
'devframe/utils/crypto-token': r('devframe/src/utils/crypto-token.ts'),
2122
'devframe/utils/events': r('devframe/src/utils/events.ts'),
2223
'devframe/utils/hash': r('devframe/src/utils/hash.ts'),
23-
'devframe/utils/human-id': r('devframe/src/utils/human-id.ts'),
2424
'devframe/utils/launch-editor': r('devframe/src/utils/launch-editor.ts'),
2525
'devframe/utils/nanoid': r('devframe/src/utils/nanoid.ts'),
2626
'devframe/utils/open': r('devframe/src/utils/open.ts'),

docs/helpers/utilities.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,25 +74,27 @@ const wire = structuredCloneStringify(new Map([['a', 1]]))
7474
const value = structuredCloneParse<Map<string, number>>(wire)
7575
```
7676

77-
### `devframe/utils/human-id`
77+
### `devframe/utils/nanoid`
7878

79-
Generate a human-readable, lowercase, dash-separated random ID.
79+
Tiny URL-safe random ID generator (vendored, no runtime dependency).
8080

8181
```ts
82-
import { humanId } from 'devframe/utils/human-id'
82+
import { nanoid } from 'devframe/utils/nanoid'
8383

84-
humanId() // 'bright-orange-tiger'
84+
nanoid() // 21 chars
85+
nanoid(10) // 10 chars
8586
```
8687

87-
### `devframe/utils/nanoid`
88+
### `devframe/utils/crypto-token`
8889

89-
Tiny URL-safe random ID generator (vendored, no runtime dependency).
90+
Cryptographically-secure token helpers built on the WebCrypto global, so they run in browsers and Node alike. Use these for bearer credentials and human-typed one-time codes.
9091

9192
```ts
92-
import { nanoid } from 'devframe/utils/nanoid'
93+
import { randomDigits, randomToken, timingSafeEqual } from 'devframe/utils/crypto-token'
9394

94-
nanoid() // 21 chars
95-
nanoid(10) // 10 chars
95+
randomToken() // 32-char hex, 128 bits of entropy — use as a bearer token
96+
randomDigits(6) // '047204' — uniform, leading zeros preserved
97+
timingSafeEqual(input, secret) // constant-time string comparison
9698
```
9799

98100
### `devframe/utils/promise`

packages/devframe/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@
4040
"./rpc/transports/ws-server": "./dist/rpc/transports/ws-server.mjs",
4141
"./types": "./dist/types/index.mjs",
4242
"./utils/colors": "./dist/utils/colors.mjs",
43+
"./utils/crypto-token": "./dist/utils/crypto-token.mjs",
4344
"./utils/events": "./dist/utils/events.mjs",
4445
"./utils/hash": "./dist/utils/hash.mjs",
45-
"./utils/human-id": "./dist/utils/human-id.mjs",
4646
"./utils/launch-editor": "./dist/utils/launch-editor.mjs",
4747
"./utils/nanoid": "./dist/utils/nanoid.mjs",
4848
"./utils/open": "./dist/utils/open.mjs",

packages/devframe/src/client/rpc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ import {
77
DEVFRAME_CONNECTION_META_FILENAME,
88
} from 'devframe/constants'
99
import { RpcCacheManager, RpcFunctionsCollectorBase } from 'devframe/rpc'
10+
import { randomToken } from 'devframe/utils/crypto-token'
1011
import { createEventEmitter } from 'devframe/utils/events'
11-
import { humanId } from 'devframe/utils/human-id'
1212
import { createRpcSharedStateClientHost } from './rpc-shared-state'
1313
import { createStaticRpcClientMode } from './rpc-static'
1414
import { createRpcStreamingClientHost } from './rpc-streaming'
@@ -144,7 +144,7 @@ function getConnectionAuthTokenFromWindows(userAuthToken?: string): string {
144144
}
145145

146146
if (!value)
147-
value = humanId()
147+
value = randomToken()
148148

149149
localStorage.setItem(CONNECTION_AUTH_TOKEN_KEY, value)
150150
;(globalThis as any)[CONNECTION_AUTH_TOKEN_KEY] = value

packages/devframe/src/node/auth/state.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,18 @@
11
import type { DevframeNodeRpcSession } from 'devframe/types'
22
import type { SharedState } from 'devframe/utils/shared-state'
33
import type { InternalAnonymousAuthStorage } from '../hub-internals/context'
4-
import { humanId } from 'devframe/utils/human-id'
4+
import { randomDigits, timingSafeEqual } from 'devframe/utils/crypto-token'
5+
6+
/** Number of decimal digits in a human-typed one-time pairing code. */
7+
const TEMP_AUTH_TOKEN_LENGTH = 6
8+
/**
9+
* How long a pairing code stays valid after it is (re)generated. A 6-digit
10+
* code only has ~20 bits of entropy, so a short lifetime plus the attempt cap
11+
* below are what keep it brute-force resistant.
12+
*/
13+
const TEMP_AUTH_TOKEN_TTL = 5 * 60_000
14+
/** Failed attempts allowed against a single code before it is rotated. */
15+
const TEMP_AUTH_MAX_ATTEMPTS = 5
516

617
export interface PendingAuthRequest {
718
clientAuthToken: string
@@ -15,17 +26,26 @@ export interface PendingAuthRequest {
1526

1627
let pendingAuth: PendingAuthRequest | null = null
1728
let tempAuthToken: string = generateTempId()
29+
let tempAuthExpiresAt: number = Date.now() + TEMP_AUTH_TOKEN_TTL
30+
let tempAuthFailedAttempts = 0
1831

1932
function generateTempId(): string {
20-
return humanId()
33+
return randomDigits(TEMP_AUTH_TOKEN_LENGTH)
2134
}
2235

2336
export function getTempAuthToken(): string {
2437
return tempAuthToken
2538
}
2639

40+
/**
41+
* Rotate the pairing code, resetting its expiry window and failed-attempt
42+
* counter. Adapters call this when a new pairing flow begins so the displayed
43+
* code is freshly valid.
44+
*/
2745
export function refreshTempAuthToken(): string {
2846
tempAuthToken = generateTempId()
47+
tempAuthExpiresAt = Date.now() + TEMP_AUTH_TOKEN_TTL
48+
tempAuthFailedAttempts = 0
2949
return tempAuthToken
3050
}
3151

@@ -49,14 +69,34 @@ export function abortPendingAuth(): void {
4969
}
5070

5171
/**
52-
* Consume the temp auth ID: verify it matches, trust the pending client, and clean up.
53-
* Returns the client's authToken if successful, null otherwise.
72+
* Consume the temp auth code: verify it matches an active, unexpired pairing
73+
* code, trust the pending client, and clean up. Returns the client's authToken
74+
* on success, `null` otherwise.
75+
*
76+
* Because the code is short and human-typed, verification is hardened against
77+
* brute force: it requires a live pending request, enforces a time-to-live,
78+
* compares in constant time, and rotates the code after
79+
* {@link TEMP_AUTH_MAX_ATTEMPTS} failed attempts so an attacker cannot keep
80+
* guessing against the same code.
5481
*/
5582
export function consumeTempAuthToken(
5683
id: string,
5784
storage: SharedState<InternalAnonymousAuthStorage>,
5885
): string | null {
59-
if (id !== tempAuthToken || !pendingAuth) {
86+
if (!pendingAuth)
87+
return null
88+
89+
// Expired code: rotate so a stale code can never be redeemed.
90+
if (Date.now() > tempAuthExpiresAt) {
91+
refreshTempAuthToken()
92+
return null
93+
}
94+
95+
if (!timingSafeEqual(id, tempAuthToken)) {
96+
tempAuthFailedAttempts += 1
97+
// Too many wrong guesses — invalidate this code entirely.
98+
if (tempAuthFailedAttempts >= TEMP_AUTH_MAX_ATTEMPTS)
99+
refreshTempAuthToken()
60100
return null
61101
}
62102

packages/devframe/src/node/hub-internals/context.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { DevframeNodeContext } from 'devframe/types'
22
import type { SharedState } from 'devframe/utils/shared-state'
3-
import { humanId } from 'devframe/utils/human-id'
3+
import { randomToken } from 'devframe/utils/crypto-token'
44
import { join } from 'pathe'
55
import { revokeActiveConnectionsForToken, revokeAuthToken } from '../auth/revoke'
66
import { createStorage } from '../storage'
@@ -80,7 +80,7 @@ export function getInternalContext(context: DevframeNodeContext): DevframeIntern
8080
revokeAuthToken: (token: string) => revokeAuthToken(context, storage, token),
8181
remoteTokens,
8282
allocateRemoteToken(dockId, origin, originLock) {
83-
const token = humanId()
83+
const token = randomToken()
8484
remoteTokens.set(token, { dockId, origin, originLock })
8585
return token
8686
},
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
// Cryptographically-secure token helpers built on the WebCrypto global
2+
// (`globalThis.crypto`), which is present in browsers and Node 19+. Kept free
3+
// of node builtins so it stays runtime-agnostic (see `test/runtime-agnostic.test.ts`)
4+
// and can be shared by browser-side client code and node-side auth code alike.
5+
//
6+
// `getRandomValues` is available in both secure and insecure contexts, unlike
7+
// `crypto.randomUUID`, so it works even when a devtool is reached over plain
8+
// HTTP on a LAN address.
9+
10+
const HEX = '0123456789abcdef'
11+
12+
/**
13+
* Generate a high-entropy, URL-safe (hex) random token suitable for use as a
14+
* bearer credential — e.g. the persistent client auth token or an ephemeral
15+
* remote-dock token. Defaults to 16 bytes (128 bits) of entropy.
16+
*/
17+
export function randomToken(byteLength = 16): string {
18+
const bytes = new Uint8Array(byteLength)
19+
globalThis.crypto.getRandomValues(bytes)
20+
let out = ''
21+
for (let i = 0; i < bytes.length; i++)
22+
out += HEX[bytes[i] >> 4] + HEX[bytes[i] & 0x0F]
23+
return out
24+
}
25+
26+
/**
27+
* Generate a uniformly-distributed string of decimal digits using rejection
28+
* sampling to avoid modulo bias. Intended for short, human-typed one-time
29+
* codes (e.g. a 6-digit pairing code). Leading zeros are preserved.
30+
*/
31+
export function randomDigits(length: number): string {
32+
// Largest multiple of 10 that fits in a byte; reject values at/above it so
33+
// every digit is equally likely.
34+
const limit = 250
35+
const buf = new Uint8Array(1)
36+
let out = ''
37+
while (out.length < length) {
38+
globalThis.crypto.getRandomValues(buf)
39+
if (buf[0] < limit)
40+
out += String(buf[0] % 10)
41+
}
42+
return out
43+
}
44+
45+
/**
46+
* Constant-time string equality. Compares every character so the comparison
47+
* time does not depend on the position of the first mismatch, mitigating
48+
* timing side-channels when verifying secrets.
49+
*
50+
* Length is treated as public (it short-circuits on differing lengths), which
51+
* is appropriate for fixed-length codes and tokens.
52+
*/
53+
export function timingSafeEqual(a: string, b: string): boolean {
54+
if (a.length !== b.length)
55+
return false
56+
let mismatch = 0
57+
for (let i = 0; i < a.length; i++)
58+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i)
59+
return mismatch === 0
60+
}

packages/devframe/src/utils/human-id.ts

Lines changed: 0 additions & 21 deletions
This file was deleted.

packages/devframe/test/runtime-agnostic.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ import { describe, expect, it } from 'vitest'
1010
const AGNOSTIC_ENTRIES = [
1111
'client/index.mjs',
1212
'utils/colors.mjs',
13+
'utils/crypto-token.mjs',
1314
'utils/events.mjs',
1415
'utils/hash.mjs',
15-
'utils/human-id.mjs',
1616
'utils/nanoid.mjs',
1717
'utils/promise.mjs',
1818
'utils/shared-state.mjs',

packages/devframe/tsdown.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ const deps = {
5959
const clientEntries = {
6060
'client/index': 'src/client/index.ts',
6161
'utils/colors': 'src/utils/colors.ts',
62+
'utils/crypto-token': 'src/utils/crypto-token.ts',
6263
'utils/events': 'src/utils/events.ts',
6364
'utils/hash': 'src/utils/hash.ts',
64-
'utils/human-id': 'src/utils/human-id.ts',
6565
'utils/nanoid': 'src/utils/nanoid.ts',
6666
'utils/promise': 'src/utils/promise.ts',
6767
'utils/shared-state': 'src/utils/shared-state.ts',
@@ -128,9 +128,9 @@ export default defineConfig([
128128
entries: [
129129
resolve(distDir, 'client/index.mjs'),
130130
resolve(distDir, 'utils/colors.mjs'),
131+
resolve(distDir, 'utils/crypto-token.mjs'),
131132
resolve(distDir, 'utils/events.mjs'),
132133
resolve(distDir, 'utils/hash.mjs'),
133-
resolve(distDir, 'utils/human-id.mjs'),
134134
resolve(distDir, 'utils/nanoid.mjs'),
135135
resolve(distDir, 'utils/promise.mjs'),
136136
resolve(distDir, 'utils/shared-state.mjs'),

0 commit comments

Comments
 (0)