@clawchatsai/connector 0.0.14 → 0.0.16
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/dist/auth-handler.d.ts +58 -0
- package/dist/auth-handler.js +252 -0
- package/dist/gateway-bridge.d.ts +14 -0
- package/dist/google-jwt.d.ts +34 -0
- package/dist/google-jwt.js +87 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +256 -29
- package/dist/session-token.d.ts +43 -0
- package/dist/session-token.js +102 -0
- package/dist/totp.d.ts +42 -0
- package/dist/totp.js +167 -0
- package/package.json +2 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth-handler.ts — DataChannel authentication state machine
|
|
3
|
+
*
|
|
4
|
+
* Manages the auth flow for each DataChannel connection:
|
|
5
|
+
* 1. Send auth-required with nonce
|
|
6
|
+
* 2. Wait for auth-session (cached JWT) or auth-full (Google + TOTP)
|
|
7
|
+
* 3. Verify credentials
|
|
8
|
+
* 4. Issue session token on success
|
|
9
|
+
* 5. Block all non-auth messages until authenticated
|
|
10
|
+
*
|
|
11
|
+
* Spec: datachannel-auth-totp.md §4
|
|
12
|
+
*/
|
|
13
|
+
export interface AuthConfig {
|
|
14
|
+
userId: string;
|
|
15
|
+
totp: {
|
|
16
|
+
secret: string;
|
|
17
|
+
algorithm: string;
|
|
18
|
+
digits: number;
|
|
19
|
+
period: number;
|
|
20
|
+
enabledAt: string;
|
|
21
|
+
};
|
|
22
|
+
google: {
|
|
23
|
+
clientId: string;
|
|
24
|
+
authorizedSub: string;
|
|
25
|
+
authorizedEmail: string;
|
|
26
|
+
};
|
|
27
|
+
sessionSecret: string;
|
|
28
|
+
backupCodeHashes?: string[];
|
|
29
|
+
/** When true, skip Google ID token verification (accept 'dev-mode-no-google'). */
|
|
30
|
+
devMode?: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface DataChannelSend {
|
|
33
|
+
send: (data: string) => void;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Initialize auth for a new DataChannel connection.
|
|
37
|
+
* Sends auth-required and starts the timeout.
|
|
38
|
+
*
|
|
39
|
+
* @returns true if auth was initiated, false if connection is blocked
|
|
40
|
+
*/
|
|
41
|
+
export declare function initAuth(dc: DataChannelSend, connectionId: string): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Handle an incoming message on an auth-gated DataChannel.
|
|
44
|
+
*
|
|
45
|
+
* @returns 'authenticated' if the message was an auth message and auth succeeded,
|
|
46
|
+
* 'pending' if auth is still in progress,
|
|
47
|
+
* 'pass' if the connection is already authenticated (message should be processed normally),
|
|
48
|
+
* 'blocked' if the message was dropped (pre-auth non-auth message)
|
|
49
|
+
*/
|
|
50
|
+
export declare function handleAuthMessage(dc: DataChannelSend, connectionId: string, msg: Record<string, unknown>, config: AuthConfig): Promise<'authenticated' | 'pending' | 'pass' | 'blocked'>;
|
|
51
|
+
/**
|
|
52
|
+
* Clean up auth state for a disconnected DataChannel.
|
|
53
|
+
*/
|
|
54
|
+
export declare function cleanupAuth(connectionId: string): void;
|
|
55
|
+
/**
|
|
56
|
+
* Check if a connection is authenticated (no longer in auth sessions).
|
|
57
|
+
*/
|
|
58
|
+
export declare function isAuthenticated(connectionId: string): boolean;
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth-handler.ts — DataChannel authentication state machine
|
|
3
|
+
*
|
|
4
|
+
* Manages the auth flow for each DataChannel connection:
|
|
5
|
+
* 1. Send auth-required with nonce
|
|
6
|
+
* 2. Wait for auth-session (cached JWT) or auth-full (Google + TOTP)
|
|
7
|
+
* 3. Verify credentials
|
|
8
|
+
* 4. Issue session token on success
|
|
9
|
+
* 5. Block all non-auth messages until authenticated
|
|
10
|
+
*
|
|
11
|
+
* Spec: datachannel-auth-totp.md §4
|
|
12
|
+
*/
|
|
13
|
+
import * as crypto from 'node:crypto';
|
|
14
|
+
import { verifyTotp, verifyBackupCode } from './totp.js';
|
|
15
|
+
import { verifyGoogleIdToken, clearJWKSCache } from './google-jwt.js';
|
|
16
|
+
import { issueSessionToken, verifySessionToken } from './session-token.js';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Module state
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** Tracks per-connection auth state */
|
|
21
|
+
const authSessions = new Map();
|
|
22
|
+
/** Rate limiting: connectionIds blocked after too many failures */
|
|
23
|
+
const blockedConnections = new Map(); // connectionId → unblock timestamp
|
|
24
|
+
/** TOTP replay prevention */
|
|
25
|
+
let lastUsedTotpStep = 0;
|
|
26
|
+
/** Max nonces to prevent memory leaks from connection storms */
|
|
27
|
+
const MAX_PENDING_NONCES = 10;
|
|
28
|
+
const AUTH_TIMEOUT_MS = 30_000;
|
|
29
|
+
const MAX_FAILURES = 5;
|
|
30
|
+
const BLOCK_DURATION_MS = 60_000;
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Public API
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/**
|
|
35
|
+
* Initialize auth for a new DataChannel connection.
|
|
36
|
+
* Sends auth-required and starts the timeout.
|
|
37
|
+
*
|
|
38
|
+
* @returns true if auth was initiated, false if connection is blocked
|
|
39
|
+
*/
|
|
40
|
+
export function initAuth(dc, connectionId) {
|
|
41
|
+
// Check if this connection is rate-limited
|
|
42
|
+
const blockedUntil = blockedConnections.get(connectionId);
|
|
43
|
+
if (blockedUntil && Date.now() < blockedUntil) {
|
|
44
|
+
dc.send(JSON.stringify({
|
|
45
|
+
type: 'auth-failed',
|
|
46
|
+
reason: 'rate_limited',
|
|
47
|
+
}));
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
blockedConnections.delete(connectionId);
|
|
51
|
+
// Cap pending nonces to prevent memory leaks
|
|
52
|
+
if (authSessions.size >= MAX_PENDING_NONCES) {
|
|
53
|
+
// Evict oldest
|
|
54
|
+
let oldestKey = null;
|
|
55
|
+
let oldestTime = Infinity;
|
|
56
|
+
for (const [key, session] of authSessions) {
|
|
57
|
+
if (session.createdAt < oldestTime) {
|
|
58
|
+
oldestTime = session.createdAt;
|
|
59
|
+
oldestKey = key;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (oldestKey) {
|
|
63
|
+
const old = authSessions.get(oldestKey);
|
|
64
|
+
clearTimeout(old.timeoutHandle);
|
|
65
|
+
authSessions.delete(oldestKey);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const nonce = crypto.randomBytes(32).toString('hex');
|
|
69
|
+
const timeoutHandle = setTimeout(() => {
|
|
70
|
+
const session = authSessions.get(connectionId);
|
|
71
|
+
if (session && session.state === 'awaiting-auth') {
|
|
72
|
+
dc.send(JSON.stringify({
|
|
73
|
+
type: 'auth-failed',
|
|
74
|
+
reason: 'auth_timeout',
|
|
75
|
+
}));
|
|
76
|
+
authSessions.delete(connectionId);
|
|
77
|
+
}
|
|
78
|
+
}, AUTH_TIMEOUT_MS);
|
|
79
|
+
authSessions.set(connectionId, {
|
|
80
|
+
state: 'awaiting-auth',
|
|
81
|
+
nonce,
|
|
82
|
+
connectionId,
|
|
83
|
+
failCount: 0,
|
|
84
|
+
createdAt: Date.now(),
|
|
85
|
+
timeoutHandle,
|
|
86
|
+
});
|
|
87
|
+
dc.send(JSON.stringify({
|
|
88
|
+
type: 'auth-required',
|
|
89
|
+
nonce,
|
|
90
|
+
}));
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Handle an incoming message on an auth-gated DataChannel.
|
|
95
|
+
*
|
|
96
|
+
* @returns 'authenticated' if the message was an auth message and auth succeeded,
|
|
97
|
+
* 'pending' if auth is still in progress,
|
|
98
|
+
* 'pass' if the connection is already authenticated (message should be processed normally),
|
|
99
|
+
* 'blocked' if the message was dropped (pre-auth non-auth message)
|
|
100
|
+
*/
|
|
101
|
+
export async function handleAuthMessage(dc, connectionId, msg, config) {
|
|
102
|
+
const session = authSessions.get(connectionId);
|
|
103
|
+
// No auth session = already authenticated or unknown connection
|
|
104
|
+
if (!session) {
|
|
105
|
+
return 'pass';
|
|
106
|
+
}
|
|
107
|
+
// Already authenticated (shouldn't happen, but be safe)
|
|
108
|
+
if (session.state === 'authenticated') {
|
|
109
|
+
clearTimeout(session.timeoutHandle);
|
|
110
|
+
authSessions.delete(connectionId);
|
|
111
|
+
return 'pass';
|
|
112
|
+
}
|
|
113
|
+
// Only accept auth messages pre-auth
|
|
114
|
+
const msgType = msg['type'];
|
|
115
|
+
if (msgType !== 'auth-session' && msgType !== 'auth-full') {
|
|
116
|
+
return 'blocked';
|
|
117
|
+
}
|
|
118
|
+
// Handle auth-session (cached JWT)
|
|
119
|
+
if (msgType === 'auth-session') {
|
|
120
|
+
const token = msg['sessionToken'];
|
|
121
|
+
if (!token) {
|
|
122
|
+
return sendFailure(dc, connectionId, 'invalid_session');
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
verifySessionToken(token, config.sessionSecret, config.userId, config.google.authorizedSub);
|
|
126
|
+
return authSuccess(dc, connectionId);
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
const reason = e.message;
|
|
130
|
+
// On expired session, don't count as failure — just prompt for full auth
|
|
131
|
+
if (reason === 'expired_session') {
|
|
132
|
+
dc.send(JSON.stringify({ type: 'auth-failed', reason: 'expired_session' }));
|
|
133
|
+
return 'pending';
|
|
134
|
+
}
|
|
135
|
+
return sendFailure(dc, connectionId, 'invalid_session');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Handle auth-full (Google ID token + TOTP)
|
|
139
|
+
if (msgType === 'auth-full') {
|
|
140
|
+
const idToken = msg['idToken'];
|
|
141
|
+
const totp = msg['totp'];
|
|
142
|
+
const sessionDays = msg['sessionDays'] || 7;
|
|
143
|
+
const msgNonce = msg['nonce'];
|
|
144
|
+
// Verify nonce
|
|
145
|
+
if (msgNonce !== session.nonce) {
|
|
146
|
+
return sendFailure(dc, connectionId, 'nonce_mismatch');
|
|
147
|
+
}
|
|
148
|
+
// Verify Google ID token (skipped in dev mode)
|
|
149
|
+
if (config.devMode && idToken === 'dev-mode-no-google') {
|
|
150
|
+
// Dev mode: skip Google verification
|
|
151
|
+
console.log('[Auth] Dev mode — skipping Google ID token verification');
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
try {
|
|
155
|
+
await verifyGoogleIdToken(idToken, {
|
|
156
|
+
clientId: config.google.clientId,
|
|
157
|
+
authorizedSub: config.google.authorizedSub,
|
|
158
|
+
authorizedEmail: config.google.authorizedEmail,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
console.error(`[Auth] Google ID token verification failed: ${e.message}`);
|
|
163
|
+
// Retry with fresh JWKS on first failure (key rotation)
|
|
164
|
+
clearJWKSCache();
|
|
165
|
+
try {
|
|
166
|
+
await verifyGoogleIdToken(idToken, {
|
|
167
|
+
clientId: config.google.clientId,
|
|
168
|
+
authorizedSub: config.google.authorizedSub,
|
|
169
|
+
authorizedEmail: config.google.authorizedEmail,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return sendFailure(dc, connectionId, 'invalid_id_token');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// Verify TOTP or backup code
|
|
178
|
+
const totpStep = verifyTotp(totp, config.totp.secret, lastUsedTotpStep);
|
|
179
|
+
if (totpStep >= 0) {
|
|
180
|
+
lastUsedTotpStep = totpStep;
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
// Try backup code
|
|
184
|
+
const backupIndex = config.backupCodeHashes
|
|
185
|
+
? verifyBackupCode(totp, config.backupCodeHashes)
|
|
186
|
+
: -1;
|
|
187
|
+
if (backupIndex >= 0) {
|
|
188
|
+
// Consume the backup code
|
|
189
|
+
config.backupCodeHashes.splice(backupIndex, 1);
|
|
190
|
+
// Caller should persist the updated config
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
return sendFailure(dc, connectionId, 'invalid_totp');
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Issue session token
|
|
197
|
+
const sessionToken = issueSessionToken(config.userId, config.google.authorizedSub, sessionDays, config.sessionSecret);
|
|
198
|
+
const expiresAt = new Date(Date.now() + Math.min(Math.max(sessionDays, 1), 30) * 86400 * 1000).toISOString();
|
|
199
|
+
clearTimeout(session.timeoutHandle);
|
|
200
|
+
authSessions.delete(connectionId);
|
|
201
|
+
dc.send(JSON.stringify({
|
|
202
|
+
type: 'auth-ok',
|
|
203
|
+
sessionToken,
|
|
204
|
+
expiresAt,
|
|
205
|
+
}));
|
|
206
|
+
return 'authenticated';
|
|
207
|
+
}
|
|
208
|
+
return 'blocked';
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Clean up auth state for a disconnected DataChannel.
|
|
212
|
+
*/
|
|
213
|
+
export function cleanupAuth(connectionId) {
|
|
214
|
+
const session = authSessions.get(connectionId);
|
|
215
|
+
if (session) {
|
|
216
|
+
clearTimeout(session.timeoutHandle);
|
|
217
|
+
authSessions.delete(connectionId);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Check if a connection is authenticated (no longer in auth sessions).
|
|
222
|
+
*/
|
|
223
|
+
export function isAuthenticated(connectionId) {
|
|
224
|
+
return !authSessions.has(connectionId);
|
|
225
|
+
}
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
// Internal helpers
|
|
228
|
+
// ---------------------------------------------------------------------------
|
|
229
|
+
function authSuccess(dc, connectionId) {
|
|
230
|
+
const session = authSessions.get(connectionId);
|
|
231
|
+
if (session) {
|
|
232
|
+
clearTimeout(session.timeoutHandle);
|
|
233
|
+
authSessions.delete(connectionId);
|
|
234
|
+
}
|
|
235
|
+
dc.send(JSON.stringify({ type: 'auth-ok' }));
|
|
236
|
+
return 'authenticated';
|
|
237
|
+
}
|
|
238
|
+
function sendFailure(dc, connectionId, reason) {
|
|
239
|
+
const session = authSessions.get(connectionId);
|
|
240
|
+
if (!session)
|
|
241
|
+
return 'pending';
|
|
242
|
+
session.failCount++;
|
|
243
|
+
if (session.failCount >= MAX_FAILURES) {
|
|
244
|
+
dc.send(JSON.stringify({ type: 'auth-failed', reason: 'rate_limited' }));
|
|
245
|
+
clearTimeout(session.timeoutHandle);
|
|
246
|
+
authSessions.delete(connectionId);
|
|
247
|
+
blockedConnections.set(connectionId, Date.now() + BLOCK_DURATION_MS);
|
|
248
|
+
return 'pending';
|
|
249
|
+
}
|
|
250
|
+
dc.send(JSON.stringify({ type: 'auth-failed', reason }));
|
|
251
|
+
return 'pending';
|
|
252
|
+
}
|
package/dist/gateway-bridge.d.ts
CHANGED
|
@@ -17,6 +17,20 @@ export interface PluginConfig {
|
|
|
17
17
|
devicePrivateKey?: string;
|
|
18
18
|
schemaVersion: number;
|
|
19
19
|
installedAt: string;
|
|
20
|
+
totp?: {
|
|
21
|
+
secret: string;
|
|
22
|
+
algorithm: string;
|
|
23
|
+
digits: number;
|
|
24
|
+
period: number;
|
|
25
|
+
enabledAt: string;
|
|
26
|
+
};
|
|
27
|
+
google?: {
|
|
28
|
+
clientId: string;
|
|
29
|
+
authorizedSub: string;
|
|
30
|
+
authorizedEmail: string;
|
|
31
|
+
};
|
|
32
|
+
sessionSecret?: string;
|
|
33
|
+
backupCodeHashes?: string[];
|
|
20
34
|
}
|
|
21
35
|
export interface BridgeConfig {
|
|
22
36
|
gatewayToken: string;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* google-jwt.ts — Google ID Token verification
|
|
3
|
+
*
|
|
4
|
+
* Verifies Google-signed JWTs against Google's public JWKS.
|
|
5
|
+
* Uses `jose` for cryptographic verification.
|
|
6
|
+
*
|
|
7
|
+
* Spec: datachannel-auth-totp.md §5.1
|
|
8
|
+
*/
|
|
9
|
+
export interface GoogleIdTokenClaims {
|
|
10
|
+
iss: string;
|
|
11
|
+
sub: string;
|
|
12
|
+
aud: string;
|
|
13
|
+
email: string;
|
|
14
|
+
email_verified: boolean;
|
|
15
|
+
exp: number;
|
|
16
|
+
iat: number;
|
|
17
|
+
}
|
|
18
|
+
export interface VerifyGoogleIdTokenOptions {
|
|
19
|
+
clientId: string;
|
|
20
|
+
authorizedSub: string;
|
|
21
|
+
authorizedEmail: string;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Verify a Google ID token JWT.
|
|
25
|
+
*
|
|
26
|
+
* @param idToken The raw JWT string from the browser
|
|
27
|
+
* @param options Expected clientId, sub, and email
|
|
28
|
+
* @returns The verified claims, or throws on failure
|
|
29
|
+
*/
|
|
30
|
+
export declare function verifyGoogleIdToken(idToken: string, options: VerifyGoogleIdTokenOptions): Promise<GoogleIdTokenClaims>;
|
|
31
|
+
/**
|
|
32
|
+
* Invalidate the JWKS cache (e.g., on key rotation failure).
|
|
33
|
+
*/
|
|
34
|
+
export declare function clearJWKSCache(): void;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* google-jwt.ts — Google ID Token verification
|
|
3
|
+
*
|
|
4
|
+
* Verifies Google-signed JWTs against Google's public JWKS.
|
|
5
|
+
* Uses `jose` for cryptographic verification.
|
|
6
|
+
*
|
|
7
|
+
* Spec: datachannel-auth-totp.md §5.1
|
|
8
|
+
*/
|
|
9
|
+
import * as https from 'node:https';
|
|
10
|
+
// jose is imported dynamically to allow graceful failure if not installed
|
|
11
|
+
let joseModule = null;
|
|
12
|
+
async function getJose() {
|
|
13
|
+
if (!joseModule) {
|
|
14
|
+
joseModule = await import('jose');
|
|
15
|
+
}
|
|
16
|
+
return joseModule;
|
|
17
|
+
}
|
|
18
|
+
let jwksCache = null;
|
|
19
|
+
const GOOGLE_JWKS_URI = 'https://www.googleapis.com/oauth2/v3/certs';
|
|
20
|
+
const GOOGLE_ISSUERS = ['https://accounts.google.com', 'accounts.google.com'];
|
|
21
|
+
/**
|
|
22
|
+
* Fetch Google's JWKS, with caching based on Cache-Control header.
|
|
23
|
+
*/
|
|
24
|
+
async function fetchJWKS() {
|
|
25
|
+
if (jwksCache && Date.now() < jwksCache.expiresAt) {
|
|
26
|
+
return jwksCache.keys;
|
|
27
|
+
}
|
|
28
|
+
const jose = await getJose();
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
https.get(GOOGLE_JWKS_URI, (res) => {
|
|
31
|
+
let data = '';
|
|
32
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
33
|
+
res.on('end', () => {
|
|
34
|
+
try {
|
|
35
|
+
const keys = JSON.parse(data);
|
|
36
|
+
// Parse Cache-Control max-age
|
|
37
|
+
const cacheControl = res.headers['cache-control'] || '';
|
|
38
|
+
const maxAgeMatch = cacheControl.match(/max-age=(\d+)/);
|
|
39
|
+
const maxAge = maxAgeMatch ? parseInt(maxAgeMatch[1], 10) : 3600;
|
|
40
|
+
jwksCache = {
|
|
41
|
+
keys,
|
|
42
|
+
expiresAt: Date.now() + maxAge * 1000,
|
|
43
|
+
};
|
|
44
|
+
resolve(keys);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
reject(new Error(`Failed to parse Google JWKS: ${e.message}`));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}).on('error', reject);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Verify a Google ID token JWT.
|
|
55
|
+
*
|
|
56
|
+
* @param idToken The raw JWT string from the browser
|
|
57
|
+
* @param options Expected clientId, sub, and email
|
|
58
|
+
* @returns The verified claims, or throws on failure
|
|
59
|
+
*/
|
|
60
|
+
export async function verifyGoogleIdToken(idToken, options) {
|
|
61
|
+
const jose = await getJose();
|
|
62
|
+
// Fetch JWKS
|
|
63
|
+
const jwksData = await fetchJWKS();
|
|
64
|
+
const JWKS = jose.createLocalJWKSet(jwksData);
|
|
65
|
+
// Verify signature and decode
|
|
66
|
+
const { payload } = await jose.jwtVerify(idToken, JWKS, {
|
|
67
|
+
issuer: GOOGLE_ISSUERS,
|
|
68
|
+
audience: options.clientId,
|
|
69
|
+
});
|
|
70
|
+
const claims = payload;
|
|
71
|
+
// Verify sub matches authorized user
|
|
72
|
+
if (claims.sub !== options.authorizedSub) {
|
|
73
|
+
throw new Error(`Google account mismatch: expected sub ${options.authorizedSub}, ` +
|
|
74
|
+
`got ${claims.sub} (${claims.email})`);
|
|
75
|
+
}
|
|
76
|
+
// Verify email matches (belt + suspenders)
|
|
77
|
+
if (claims.email !== options.authorizedEmail) {
|
|
78
|
+
throw new Error(`Google email mismatch: expected ${options.authorizedEmail}, got ${claims.email}`);
|
|
79
|
+
}
|
|
80
|
+
return claims;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Invalidate the JWKS cache (e.g., on key rotation failure).
|
|
84
|
+
*/
|
|
85
|
+
export function clearJWKSCache() {
|
|
86
|
+
jwksCache = null;
|
|
87
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* Spec: specs/multitenant-p2p.md sections 6.1-6.2
|
|
11
11
|
*/
|
|
12
12
|
export declare const PLUGIN_ID = "connector";
|
|
13
|
-
export declare const PLUGIN_VERSION = "0.0.
|
|
13
|
+
export declare const PLUGIN_VERSION = "0.0.16";
|
|
14
14
|
interface PluginServiceContext {
|
|
15
15
|
stateDir: string;
|
|
16
16
|
logger: {
|
package/dist/index.js
CHANGED
|
@@ -16,14 +16,19 @@ import { SignalingClient } from './signaling-client.js';
|
|
|
16
16
|
import { WebRTCPeerManager } from './webrtc-peer.js';
|
|
17
17
|
import { dispatchRpc } from './shim.js';
|
|
18
18
|
import { checkForUpdates, performUpdate } from './updater.js';
|
|
19
|
+
import { initAuth, handleAuthMessage, cleanupAuth } from './auth-handler.js';
|
|
20
|
+
import { generateTotpSecret, verifyTotp, generateBackupCodes, buildOtpauthUri } from './totp.js';
|
|
21
|
+
import { generateSessionSecret } from './session-token.js';
|
|
19
22
|
// Inline from shared/api-version.ts to avoid rootDir conflict
|
|
20
23
|
const CURRENT_API_VERSION = 1;
|
|
21
24
|
export const PLUGIN_ID = 'connector';
|
|
22
|
-
export const PLUGIN_VERSION = '0.0.
|
|
25
|
+
export const PLUGIN_VERSION = '0.0.16';
|
|
23
26
|
/** Max DataChannel message size (~256KB, leave room for envelope) */
|
|
24
27
|
const MAX_DC_MESSAGE_SIZE = 256 * 1024;
|
|
25
28
|
/** Active DataChannel connections: connectionId → send function */
|
|
26
29
|
const connectedClients = new Map();
|
|
30
|
+
/** Reassembly buffers for chunked gateway-msg from browser (large payloads like image attachments). */
|
|
31
|
+
const gatewayMsgChunkBuffers = new Map();
|
|
27
32
|
let app = null;
|
|
28
33
|
let signaling = null;
|
|
29
34
|
let webrtcPeer = null;
|
|
@@ -48,6 +53,10 @@ function loadConfig() {
|
|
|
48
53
|
return null;
|
|
49
54
|
}
|
|
50
55
|
}
|
|
56
|
+
function saveConfig(config) {
|
|
57
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
58
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
59
|
+
}
|
|
51
60
|
// ---------------------------------------------------------------------------
|
|
52
61
|
// Service lifecycle
|
|
53
62
|
// ---------------------------------------------------------------------------
|
|
@@ -265,35 +274,145 @@ async function stopClawChats(ctx) {
|
|
|
265
274
|
// DataChannel message handler (spec section 6.4)
|
|
266
275
|
// ---------------------------------------------------------------------------
|
|
267
276
|
function setupDataChannelHandler(dc, connectionId, ctx) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
277
|
+
// Build auth config from plugin config
|
|
278
|
+
const config = loadConfig();
|
|
279
|
+
const isDevMode = process.env.CLAWCHATS_DEV === 'true';
|
|
280
|
+
const hasGoogle = !!config?.google;
|
|
281
|
+
const authEnabled = config?.schemaVersion === 2 && config?.totp && config?.sessionSecret
|
|
282
|
+
&& (hasGoogle || isDevMode);
|
|
283
|
+
if (authEnabled) {
|
|
284
|
+
// In dev mode without Google identity, use placeholder
|
|
285
|
+
const google = config.google ?? {
|
|
286
|
+
clientId: 'dev-placeholder',
|
|
287
|
+
authorizedSub: 'dev-placeholder',
|
|
288
|
+
authorizedEmail: 'dev@localhost',
|
|
289
|
+
};
|
|
290
|
+
// Auth-gated: don't add to broadcast clients until authenticated
|
|
291
|
+
const authConfig = {
|
|
292
|
+
userId: config.userId,
|
|
293
|
+
totp: config.totp,
|
|
294
|
+
google,
|
|
295
|
+
sessionSecret: config.sessionSecret,
|
|
296
|
+
backupCodeHashes: config.backupCodeHashes,
|
|
297
|
+
devMode: isDevMode,
|
|
298
|
+
};
|
|
299
|
+
const authStarted = initAuth(dc, connectionId);
|
|
300
|
+
if (!authStarted)
|
|
301
|
+
return; // rate-limited, DC will be closed
|
|
302
|
+
dc.onMessage(async (data) => {
|
|
303
|
+
let msg;
|
|
304
|
+
try {
|
|
305
|
+
msg = JSON.parse(data);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
dc.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
// Check auth state
|
|
312
|
+
const authResult = await handleAuthMessage(dc, connectionId, msg, authConfig);
|
|
313
|
+
switch (authResult) {
|
|
314
|
+
case 'authenticated':
|
|
315
|
+
// Auth succeeded — add to broadcast clients and inform gateway
|
|
316
|
+
connectedClients.set(connectionId, dc);
|
|
317
|
+
ctx.logger.info(`Browser authenticated: ${connectionId}`);
|
|
318
|
+
// Persist backup code changes if any were consumed
|
|
319
|
+
if (authConfig.backupCodeHashes && config.backupCodeHashes) {
|
|
320
|
+
config.backupCodeHashes = authConfig.backupCodeHashes;
|
|
321
|
+
saveConfig(config);
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
case 'pending':
|
|
325
|
+
// Still waiting for valid auth
|
|
326
|
+
return;
|
|
327
|
+
case 'blocked':
|
|
328
|
+
// Pre-auth non-auth message — drop silently
|
|
329
|
+
return;
|
|
330
|
+
case 'pass':
|
|
331
|
+
// Already authenticated — process message normally
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
// Authenticated message processing
|
|
335
|
+
processAuthenticatedMessage(dc, connectionId, msg, ctx);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
// No auth configured (schemaVersion 1 or missing TOTP)
|
|
340
|
+
if (config && config.schemaVersion !== undefined && config.schemaVersion < 2) {
|
|
341
|
+
ctx.logger.warn('TOTP not configured — DataChannel access blocked. Run: openclaw clawchats reauth');
|
|
342
|
+
dc.send(JSON.stringify({
|
|
343
|
+
type: 'auth-required-setup',
|
|
344
|
+
message: 'Two-factor authentication is required. Run "openclaw clawchats reauth" on your gateway machine to set it up.',
|
|
345
|
+
}));
|
|
346
|
+
return; // Don't process any messages
|
|
291
347
|
}
|
|
292
|
-
|
|
348
|
+
// Legacy path: no config at all (shouldn't happen in normal flow)
|
|
349
|
+
connectedClients.set(connectionId, dc);
|
|
350
|
+
dc.onMessage(async (data) => {
|
|
351
|
+
let msg;
|
|
352
|
+
try {
|
|
353
|
+
msg = JSON.parse(data);
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
dc.send(JSON.stringify({ type: 'error', message: 'Invalid JSON' }));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
processAuthenticatedMessage(dc, connectionId, msg, ctx);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
293
362
|
dc.onClosed(() => {
|
|
294
363
|
connectedClients.delete(connectionId);
|
|
364
|
+
cleanupAuth(connectionId);
|
|
295
365
|
});
|
|
296
366
|
}
|
|
367
|
+
/**
|
|
368
|
+
* Process a message on an authenticated DataChannel.
|
|
369
|
+
*/
|
|
370
|
+
function processAuthenticatedMessage(dc, connectionId, msg, ctx) {
|
|
371
|
+
switch (msg['type']) {
|
|
372
|
+
case 'rpc':
|
|
373
|
+
handleRpcMessage(dc, msg, ctx);
|
|
374
|
+
break;
|
|
375
|
+
case 'gateway-msg':
|
|
376
|
+
if (app?.gatewayClient && typeof msg['payload'] === 'string') {
|
|
377
|
+
app.gatewayClient.sendToGateway(msg['payload']);
|
|
378
|
+
}
|
|
379
|
+
break;
|
|
380
|
+
case 'gateway-msg-chunk': {
|
|
381
|
+
const chunkId = msg['id'];
|
|
382
|
+
const index = msg['index'];
|
|
383
|
+
const total = msg['total'];
|
|
384
|
+
const chunkData = msg['data'];
|
|
385
|
+
if (!chunkId || typeof index !== 'number' || typeof total !== 'number' || !chunkData) {
|
|
386
|
+
dc.send(JSON.stringify({ type: 'error', message: 'malformed gateway-msg-chunk' }));
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
if (!gatewayMsgChunkBuffers.has(chunkId)) {
|
|
390
|
+
gatewayMsgChunkBuffers.set(chunkId, {
|
|
391
|
+
chunks: new Array(total),
|
|
392
|
+
received: 0,
|
|
393
|
+
total,
|
|
394
|
+
createdAt: Date.now(),
|
|
395
|
+
});
|
|
396
|
+
setTimeout(() => gatewayMsgChunkBuffers.delete(chunkId), 30_000);
|
|
397
|
+
}
|
|
398
|
+
const buf = gatewayMsgChunkBuffers.get(chunkId);
|
|
399
|
+
if (!buf.chunks[index]) {
|
|
400
|
+
buf.chunks[index] = chunkData;
|
|
401
|
+
buf.received++;
|
|
402
|
+
}
|
|
403
|
+
if (buf.received === buf.total) {
|
|
404
|
+
gatewayMsgChunkBuffers.delete(chunkId);
|
|
405
|
+
const fullPayload = buf.chunks.join('');
|
|
406
|
+
if (app?.gatewayClient) {
|
|
407
|
+
app.gatewayClient.sendToGateway(fullPayload);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
default:
|
|
413
|
+
dc.send(JSON.stringify({ type: 'error', message: 'unknown message type' }));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
297
416
|
async function handleRpcMessage(dc, msg, ctx) {
|
|
298
417
|
if (!app) {
|
|
299
418
|
dc.send(JSON.stringify({
|
|
@@ -446,12 +565,13 @@ async function handleSetup(token) {
|
|
|
446
565
|
apiKey,
|
|
447
566
|
}));
|
|
448
567
|
});
|
|
449
|
-
ws.on('message', (raw) => {
|
|
568
|
+
ws.on('message', async (raw) => {
|
|
450
569
|
const msg = JSON.parse(raw.toString());
|
|
451
570
|
if (msg.type === 'setup-complete') {
|
|
452
571
|
clearTimeout(timeout);
|
|
453
572
|
console.log(` User: ${msg.userId}`);
|
|
454
|
-
|
|
573
|
+
console.log(' Registering gateway... ✅');
|
|
574
|
+
// Save initial config (schemaVersion 1 — will upgrade to 2 after TOTP enrollment)
|
|
455
575
|
const config = {
|
|
456
576
|
userId: msg.userId,
|
|
457
577
|
serverUrl: tokenData.serverUrl,
|
|
@@ -460,16 +580,31 @@ async function handleSetup(token) {
|
|
|
460
580
|
schemaVersion: 1,
|
|
461
581
|
installedAt: new Date().toISOString(),
|
|
462
582
|
};
|
|
583
|
+
// Bind Google identity if provided by signaling server
|
|
584
|
+
if (msg.google) {
|
|
585
|
+
config.google = {
|
|
586
|
+
clientId: msg.google.clientId,
|
|
587
|
+
authorizedSub: msg.google.sub,
|
|
588
|
+
authorizedEmail: msg.google.email,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
463
591
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
464
|
-
|
|
592
|
+
saveConfig(config);
|
|
465
593
|
// Create data directories
|
|
466
594
|
const dataDir = path.join(CONFIG_DIR, 'data');
|
|
467
595
|
const uploadsDir = path.join(CONFIG_DIR, 'uploads');
|
|
468
596
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
469
597
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
598
|
+
ws.close();
|
|
599
|
+
// Enroll TOTP (interactive — requires stdin)
|
|
600
|
+
const totpOk = await enrollTotp(config);
|
|
601
|
+
if (!totpOk) {
|
|
602
|
+
console.log('');
|
|
603
|
+
console.log(' ⚠️ TOTP not configured. You can set it up later with: openclaw clawchats reauth');
|
|
604
|
+
console.log(' ClawChats will not allow browser connections until 2FA is enabled.');
|
|
605
|
+
}
|
|
470
606
|
console.log(' ClawChats is ready!');
|
|
471
607
|
console.log(' Open clawchats.ai in your browser to start chatting.');
|
|
472
|
-
ws.close();
|
|
473
608
|
resolve();
|
|
474
609
|
}
|
|
475
610
|
else if (msg.type === 'setup-error') {
|
|
@@ -489,6 +624,95 @@ async function handleSetup(token) {
|
|
|
489
624
|
});
|
|
490
625
|
});
|
|
491
626
|
}
|
|
627
|
+
async function enrollTotp(config) {
|
|
628
|
+
const readline = await import('node:readline');
|
|
629
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
630
|
+
const ask = (q) => new Promise(r => rl.question(q, r));
|
|
631
|
+
try {
|
|
632
|
+
// Generate TOTP secret
|
|
633
|
+
const totpSecret = generateTotpSecret();
|
|
634
|
+
const email = config.google?.authorizedEmail || config.userId;
|
|
635
|
+
const otpauthUri = buildOtpauthUri(totpSecret, email);
|
|
636
|
+
// Format secret with spaces for readability
|
|
637
|
+
const formatted = totpSecret.match(/.{1,4}/g)?.join(' ') || totpSecret;
|
|
638
|
+
console.log('');
|
|
639
|
+
console.log(' 🔐 Setting up two-factor authentication');
|
|
640
|
+
console.log('');
|
|
641
|
+
console.log(' Open this link to scan the QR code with your authenticator app:');
|
|
642
|
+
console.log(` ${config.serverUrl.replace('wss://', 'https://').replace(/\/ws\/?$/, '')}/totp-setup#${totpSecret}`);
|
|
643
|
+
console.log('');
|
|
644
|
+
console.log(` Or enter this code manually: ${formatted}`);
|
|
645
|
+
console.log('');
|
|
646
|
+
console.log(" Don't have an authenticator app?");
|
|
647
|
+
console.log(' Google Authenticator: https://apps.apple.com/app/google-authenticator/id388497605');
|
|
648
|
+
console.log(' https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2');
|
|
649
|
+
console.log('');
|
|
650
|
+
// Verification loop
|
|
651
|
+
let verified = false;
|
|
652
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
653
|
+
const code = await ask(' Enter a code from your app to verify: ');
|
|
654
|
+
const step = verifyTotp(code.trim(), totpSecret, 0);
|
|
655
|
+
if (step >= 0) {
|
|
656
|
+
verified = true;
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
console.log(' ❌ Invalid code. Make sure you scanned the right QR code and try again.');
|
|
660
|
+
}
|
|
661
|
+
if (!verified) {
|
|
662
|
+
console.log(' Too many failed attempts. TOTP setup cancelled.');
|
|
663
|
+
rl.close();
|
|
664
|
+
return false;
|
|
665
|
+
}
|
|
666
|
+
console.log(' ✅ Two-factor authentication enabled!');
|
|
667
|
+
// Generate backup codes
|
|
668
|
+
const { codes, hashes } = generateBackupCodes();
|
|
669
|
+
console.log('');
|
|
670
|
+
console.log(' 🔑 Backup codes (save these somewhere safe — one-time use):');
|
|
671
|
+
for (const code of codes) {
|
|
672
|
+
console.log(` ${code}`);
|
|
673
|
+
}
|
|
674
|
+
console.log('');
|
|
675
|
+
console.log(' ⚠️ These codes will NOT be shown again.');
|
|
676
|
+
// Generate session secret
|
|
677
|
+
const sessionSecret = generateSessionSecret();
|
|
678
|
+
// Update config
|
|
679
|
+
config.totp = {
|
|
680
|
+
secret: totpSecret,
|
|
681
|
+
algorithm: 'SHA1',
|
|
682
|
+
digits: 6,
|
|
683
|
+
period: 30,
|
|
684
|
+
enabledAt: new Date().toISOString(),
|
|
685
|
+
};
|
|
686
|
+
config.sessionSecret = sessionSecret;
|
|
687
|
+
config.backupCodeHashes = hashes;
|
|
688
|
+
config.schemaVersion = 2;
|
|
689
|
+
saveConfig(config);
|
|
690
|
+
console.log('');
|
|
691
|
+
rl.close();
|
|
692
|
+
return true;
|
|
693
|
+
}
|
|
694
|
+
catch (e) {
|
|
695
|
+
rl.close();
|
|
696
|
+
console.error(` TOTP setup failed: ${e.message}`);
|
|
697
|
+
return false;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
async function handleReauth() {
|
|
701
|
+
const config = loadConfig();
|
|
702
|
+
if (!config) {
|
|
703
|
+
console.error('ClawChats not configured. Run: openclaw clawchats setup <token>');
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
console.log('🔐 Re-initializing ClawChats authentication...');
|
|
707
|
+
console.log('');
|
|
708
|
+
console.log(' ⚠️ This will invalidate all existing sessions.');
|
|
709
|
+
console.log(' All connected browsers will need to re-authenticate.');
|
|
710
|
+
const success = await enrollTotp(config);
|
|
711
|
+
if (success) {
|
|
712
|
+
console.log(' All previous sessions have been invalidated.');
|
|
713
|
+
console.log(' Restart the gateway for changes to take effect: systemctl --user restart openclaw-gateway');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
492
716
|
async function handleStatus() {
|
|
493
717
|
// CLI runs in a separate process — module-level vars are null here.
|
|
494
718
|
// Query the live service via the health endpoint instead.
|
|
@@ -623,6 +847,9 @@ const plugin = {
|
|
|
623
847
|
cmd.command('status')
|
|
624
848
|
.description('Show ClawChats connection status')
|
|
625
849
|
.action(() => handleStatus());
|
|
850
|
+
cmd.command('reauth')
|
|
851
|
+
.description('Reset two-factor authentication (new TOTP secret + invalidate sessions)')
|
|
852
|
+
.action(() => handleReauth());
|
|
626
853
|
cmd.command('reset')
|
|
627
854
|
.description('Disconnect and remove all ClawChats data')
|
|
628
855
|
.action(() => handleReset());
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-token.ts — Plugin-local session JWT management
|
|
3
|
+
*
|
|
4
|
+
* Issues and verifies HS256 JWTs that let returning users skip TOTP.
|
|
5
|
+
* Signing key is stored in config.json — regenerating it invalidates
|
|
6
|
+
* all previously issued tokens.
|
|
7
|
+
*
|
|
8
|
+
* Spec: datachannel-auth-totp.md §6
|
|
9
|
+
*/
|
|
10
|
+
interface SessionPayload {
|
|
11
|
+
sub: string;
|
|
12
|
+
googleSub: string;
|
|
13
|
+
iat: number;
|
|
14
|
+
exp: number;
|
|
15
|
+
jti: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Issue a session token.
|
|
19
|
+
*
|
|
20
|
+
* @param userId The ClawChats user ID
|
|
21
|
+
* @param googleSub The Google account sub
|
|
22
|
+
* @param sessionDays Duration in days (1-30, clamped)
|
|
23
|
+
* @param secret The 256-bit hex signing key from config
|
|
24
|
+
* @returns The signed JWT string
|
|
25
|
+
*/
|
|
26
|
+
export declare function issueSessionToken(userId: string, googleSub: string, sessionDays: number, secret: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Verify a session token.
|
|
29
|
+
*
|
|
30
|
+
* @param token The JWT string from the browser
|
|
31
|
+
* @param secret The 256-bit hex signing key from config
|
|
32
|
+
* @param userId Expected userId
|
|
33
|
+
* @param googleSub Expected Google sub
|
|
34
|
+
* @returns The decoded payload if valid
|
|
35
|
+
* @throws Error with descriptive message on failure
|
|
36
|
+
*/
|
|
37
|
+
export declare function verifySessionToken(token: string, secret: string, userId: string, googleSub: string): SessionPayload;
|
|
38
|
+
/**
|
|
39
|
+
* Generate a new 256-bit session secret (hex-encoded).
|
|
40
|
+
* Call this during initial setup and on `reauth` to invalidate all sessions.
|
|
41
|
+
*/
|
|
42
|
+
export declare function generateSessionSecret(): string;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* session-token.ts — Plugin-local session JWT management
|
|
3
|
+
*
|
|
4
|
+
* Issues and verifies HS256 JWTs that let returning users skip TOTP.
|
|
5
|
+
* Signing key is stored in config.json — regenerating it invalidates
|
|
6
|
+
* all previously issued tokens.
|
|
7
|
+
*
|
|
8
|
+
* Spec: datachannel-auth-totp.md §6
|
|
9
|
+
*/
|
|
10
|
+
import * as crypto from 'node:crypto';
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// HS256 JWT implementation (minimal, no dependencies)
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
function base64urlEncode(data) {
|
|
15
|
+
const buf = typeof data === 'string' ? Buffer.from(data) : data;
|
|
16
|
+
return buf.toString('base64url');
|
|
17
|
+
}
|
|
18
|
+
function base64urlDecode(str) {
|
|
19
|
+
return Buffer.from(str, 'base64url');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Issue a session token.
|
|
23
|
+
*
|
|
24
|
+
* @param userId The ClawChats user ID
|
|
25
|
+
* @param googleSub The Google account sub
|
|
26
|
+
* @param sessionDays Duration in days (1-30, clamped)
|
|
27
|
+
* @param secret The 256-bit hex signing key from config
|
|
28
|
+
* @returns The signed JWT string
|
|
29
|
+
*/
|
|
30
|
+
export function issueSessionToken(userId, googleSub, sessionDays, secret) {
|
|
31
|
+
const days = Math.min(Math.max(Math.round(sessionDays), 1), 30);
|
|
32
|
+
const now = Math.floor(Date.now() / 1000);
|
|
33
|
+
const header = { alg: 'HS256', typ: 'JWT' };
|
|
34
|
+
const payload = {
|
|
35
|
+
sub: userId,
|
|
36
|
+
googleSub,
|
|
37
|
+
iat: now,
|
|
38
|
+
exp: now + days * 86400,
|
|
39
|
+
jti: crypto.randomUUID(),
|
|
40
|
+
};
|
|
41
|
+
const headerB64 = base64urlEncode(JSON.stringify(header));
|
|
42
|
+
const payloadB64 = base64urlEncode(JSON.stringify(payload));
|
|
43
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
44
|
+
const hmac = crypto.createHmac('sha256', Buffer.from(secret, 'hex'));
|
|
45
|
+
hmac.update(signingInput);
|
|
46
|
+
const signature = hmac.digest().toString('base64url');
|
|
47
|
+
return `${signingInput}.${signature}`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Verify a session token.
|
|
51
|
+
*
|
|
52
|
+
* @param token The JWT string from the browser
|
|
53
|
+
* @param secret The 256-bit hex signing key from config
|
|
54
|
+
* @param userId Expected userId
|
|
55
|
+
* @param googleSub Expected Google sub
|
|
56
|
+
* @returns The decoded payload if valid
|
|
57
|
+
* @throws Error with descriptive message on failure
|
|
58
|
+
*/
|
|
59
|
+
export function verifySessionToken(token, secret, userId, googleSub) {
|
|
60
|
+
const parts = token.split('.');
|
|
61
|
+
if (parts.length !== 3)
|
|
62
|
+
throw new Error('malformed_token');
|
|
63
|
+
const [headerB64, payloadB64, signatureB64] = parts;
|
|
64
|
+
// Verify signature
|
|
65
|
+
const signingInput = `${headerB64}.${payloadB64}`;
|
|
66
|
+
const hmac = crypto.createHmac('sha256', Buffer.from(secret, 'hex'));
|
|
67
|
+
hmac.update(signingInput);
|
|
68
|
+
const expectedSig = hmac.digest();
|
|
69
|
+
const actualSig = base64urlDecode(signatureB64);
|
|
70
|
+
if (expectedSig.length !== actualSig.length ||
|
|
71
|
+
!crypto.timingSafeEqual(expectedSig, actualSig)) {
|
|
72
|
+
throw new Error('invalid_signature');
|
|
73
|
+
}
|
|
74
|
+
// Decode payload
|
|
75
|
+
let payload;
|
|
76
|
+
try {
|
|
77
|
+
payload = JSON.parse(base64urlDecode(payloadB64).toString('utf8'));
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new Error('malformed_payload');
|
|
81
|
+
}
|
|
82
|
+
// Check expiry
|
|
83
|
+
const now = Math.floor(Date.now() / 1000);
|
|
84
|
+
if (payload.exp <= now)
|
|
85
|
+
throw new Error('expired_session');
|
|
86
|
+
// Check not issued in the future (clock skew tolerance: 60s)
|
|
87
|
+
if (payload.iat > now + 60)
|
|
88
|
+
throw new Error('invalid_iat');
|
|
89
|
+
// Check identity
|
|
90
|
+
if (payload.sub !== userId)
|
|
91
|
+
throw new Error('invalid_user');
|
|
92
|
+
if (payload.googleSub !== googleSub)
|
|
93
|
+
throw new Error('invalid_google_sub');
|
|
94
|
+
return payload;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Generate a new 256-bit session secret (hex-encoded).
|
|
98
|
+
* Call this during initial setup and on `reauth` to invalidate all sessions.
|
|
99
|
+
*/
|
|
100
|
+
export function generateSessionSecret() {
|
|
101
|
+
return crypto.randomBytes(32).toString('hex');
|
|
102
|
+
}
|
package/dist/totp.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* totp.ts — RFC 6238 TOTP implementation
|
|
3
|
+
*
|
|
4
|
+
* Generates and verifies 6-digit time-based one-time passwords.
|
|
5
|
+
* No external dependencies — uses Node.js built-in crypto.
|
|
6
|
+
*
|
|
7
|
+
* Spec: datachannel-auth-totp.md §5.2
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Generate a TOTP code for the current time.
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateTotp(secretBase32: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Verify a TOTP code with ±1 time-step window and replay prevention.
|
|
15
|
+
*
|
|
16
|
+
* @param code The 6-digit code from the user
|
|
17
|
+
* @param secretBase32 The base32-encoded TOTP secret
|
|
18
|
+
* @param lastUsedStep The last successfully used time step (for replay prevention)
|
|
19
|
+
* @returns The matched time step if valid, or -1 if invalid
|
|
20
|
+
*/
|
|
21
|
+
export declare function verifyTotp(code: string, secretBase32: string, lastUsedStep: number): number;
|
|
22
|
+
/**
|
|
23
|
+
* Generate a random 20-byte TOTP secret, returned as base32.
|
|
24
|
+
*/
|
|
25
|
+
export declare function generateTotpSecret(): string;
|
|
26
|
+
/**
|
|
27
|
+
* Build an otpauth:// URI for QR code generation.
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildOtpauthUri(secretBase32: string, email: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* Generate backup codes — 10 random 8-char alphanumeric codes.
|
|
32
|
+
* Returns { codes: string[], hashes: string[] }.
|
|
33
|
+
* Display `codes` to the user once. Store `hashes` in config.
|
|
34
|
+
*/
|
|
35
|
+
export declare function generateBackupCodes(): {
|
|
36
|
+
codes: string[];
|
|
37
|
+
hashes: string[];
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Verify a backup code against stored hashes. Returns the index consumed, or -1.
|
|
41
|
+
*/
|
|
42
|
+
export declare function verifyBackupCode(code: string, hashes: string[]): number;
|
package/dist/totp.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* totp.ts — RFC 6238 TOTP implementation
|
|
3
|
+
*
|
|
4
|
+
* Generates and verifies 6-digit time-based one-time passwords.
|
|
5
|
+
* No external dependencies — uses Node.js built-in crypto.
|
|
6
|
+
*
|
|
7
|
+
* Spec: datachannel-auth-totp.md §5.2
|
|
8
|
+
*/
|
|
9
|
+
import * as crypto from 'node:crypto';
|
|
10
|
+
const DIGITS = 6;
|
|
11
|
+
const PERIOD = 30;
|
|
12
|
+
const ALGORITHM = 'sha1';
|
|
13
|
+
/**
|
|
14
|
+
* Generate a TOTP code for a given time step.
|
|
15
|
+
*/
|
|
16
|
+
function generateForStep(secret, step) {
|
|
17
|
+
// Convert step to 8-byte big-endian buffer
|
|
18
|
+
const stepBuf = Buffer.alloc(8);
|
|
19
|
+
stepBuf.writeBigUInt64BE(BigInt(step));
|
|
20
|
+
// HMAC-SHA1
|
|
21
|
+
const hmac = crypto.createHmac(ALGORITHM, secret);
|
|
22
|
+
hmac.update(stepBuf);
|
|
23
|
+
const hash = hmac.digest();
|
|
24
|
+
// Dynamic truncation (RFC 4226 §5.4)
|
|
25
|
+
const offset = hash[hash.length - 1] & 0x0f;
|
|
26
|
+
const binary = ((hash[offset] & 0x7f) << 24) |
|
|
27
|
+
((hash[offset + 1] & 0xff) << 16) |
|
|
28
|
+
((hash[offset + 2] & 0xff) << 8) |
|
|
29
|
+
(hash[offset + 3] & 0xff);
|
|
30
|
+
const otp = binary % 10 ** DIGITS;
|
|
31
|
+
return otp.toString().padStart(DIGITS, '0');
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Generate a TOTP code for the current time.
|
|
35
|
+
*/
|
|
36
|
+
export function generateTotp(secretBase32) {
|
|
37
|
+
const secret = base32Decode(secretBase32);
|
|
38
|
+
const step = Math.floor(Date.now() / 1000 / PERIOD);
|
|
39
|
+
return generateForStep(secret, step);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Verify a TOTP code with ±1 time-step window and replay prevention.
|
|
43
|
+
*
|
|
44
|
+
* @param code The 6-digit code from the user
|
|
45
|
+
* @param secretBase32 The base32-encoded TOTP secret
|
|
46
|
+
* @param lastUsedStep The last successfully used time step (for replay prevention)
|
|
47
|
+
* @returns The matched time step if valid, or -1 if invalid
|
|
48
|
+
*/
|
|
49
|
+
export function verifyTotp(code, secretBase32, lastUsedStep) {
|
|
50
|
+
if (!/^\d{6}$/.test(code))
|
|
51
|
+
return -1;
|
|
52
|
+
const secret = base32Decode(secretBase32);
|
|
53
|
+
const currentStep = Math.floor(Date.now() / 1000 / PERIOD);
|
|
54
|
+
// Check T-1, T, T+1
|
|
55
|
+
for (const offset of [-1, 0, 1]) {
|
|
56
|
+
const step = currentStep + offset;
|
|
57
|
+
if (step <= lastUsedStep)
|
|
58
|
+
continue; // replay protection
|
|
59
|
+
const expected = generateForStep(secret, step);
|
|
60
|
+
if (timingSafeEqual(code, expected)) {
|
|
61
|
+
return step; // caller should update lastUsedStep to this value
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Generate a random 20-byte TOTP secret, returned as base32.
|
|
68
|
+
*/
|
|
69
|
+
export function generateTotpSecret() {
|
|
70
|
+
const secret = crypto.randomBytes(20);
|
|
71
|
+
return base32Encode(secret);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Build an otpauth:// URI for QR code generation.
|
|
75
|
+
*/
|
|
76
|
+
export function buildOtpauthUri(secretBase32, email) {
|
|
77
|
+
const issuer = 'ClawChats';
|
|
78
|
+
const label = encodeURIComponent(`${issuer}:${email}`);
|
|
79
|
+
const params = new URLSearchParams({
|
|
80
|
+
secret: secretBase32,
|
|
81
|
+
issuer,
|
|
82
|
+
algorithm: 'SHA1',
|
|
83
|
+
digits: String(DIGITS),
|
|
84
|
+
period: String(PERIOD),
|
|
85
|
+
});
|
|
86
|
+
return `otpauth://totp/${label}?${params.toString()}`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Generate backup codes — 10 random 8-char alphanumeric codes.
|
|
90
|
+
* Returns { codes: string[], hashes: string[] }.
|
|
91
|
+
* Display `codes` to the user once. Store `hashes` in config.
|
|
92
|
+
*/
|
|
93
|
+
export function generateBackupCodes() {
|
|
94
|
+
const codes = [];
|
|
95
|
+
const hashes = [];
|
|
96
|
+
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no O/0/I/1 ambiguity
|
|
97
|
+
for (let i = 0; i < 10; i++) {
|
|
98
|
+
const bytes = crypto.randomBytes(8);
|
|
99
|
+
let code = '';
|
|
100
|
+
for (let j = 0; j < 8; j++) {
|
|
101
|
+
code += charset[bytes[j] % charset.length];
|
|
102
|
+
}
|
|
103
|
+
// Format as XXXX-XXXX for readability
|
|
104
|
+
const formatted = `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
105
|
+
codes.push(formatted);
|
|
106
|
+
hashes.push(crypto.createHash('sha256').update(formatted).digest('hex'));
|
|
107
|
+
}
|
|
108
|
+
return { codes, hashes };
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Verify a backup code against stored hashes. Returns the index consumed, or -1.
|
|
112
|
+
*/
|
|
113
|
+
export function verifyBackupCode(code, hashes) {
|
|
114
|
+
const normalized = code.toUpperCase().replace(/\s/g, '');
|
|
115
|
+
const hash = crypto.createHash('sha256').update(normalized).digest('hex');
|
|
116
|
+
const index = hashes.indexOf(hash);
|
|
117
|
+
return index; // caller should remove hashes[index] after use
|
|
118
|
+
}
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Base32 encoding/decoding (RFC 4648)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
123
|
+
function base32Encode(data) {
|
|
124
|
+
let result = '';
|
|
125
|
+
let bits = 0;
|
|
126
|
+
let value = 0;
|
|
127
|
+
for (const byte of data) {
|
|
128
|
+
value = (value << 8) | byte;
|
|
129
|
+
bits += 8;
|
|
130
|
+
while (bits >= 5) {
|
|
131
|
+
bits -= 5;
|
|
132
|
+
result += BASE32_ALPHABET[(value >>> bits) & 0x1f];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (bits > 0) {
|
|
136
|
+
result += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f];
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
function base32Decode(encoded) {
|
|
141
|
+
const cleaned = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
|
|
142
|
+
const bytes = [];
|
|
143
|
+
let bits = 0;
|
|
144
|
+
let value = 0;
|
|
145
|
+
for (const char of cleaned) {
|
|
146
|
+
const idx = BASE32_ALPHABET.indexOf(char);
|
|
147
|
+
if (idx === -1)
|
|
148
|
+
continue;
|
|
149
|
+
value = (value << 5) | idx;
|
|
150
|
+
bits += 5;
|
|
151
|
+
if (bits >= 8) {
|
|
152
|
+
bits -= 8;
|
|
153
|
+
bytes.push((value >>> bits) & 0xff);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return Buffer.from(bytes);
|
|
157
|
+
}
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Timing-safe string comparison
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
function timingSafeEqual(a, b) {
|
|
162
|
+
if (a.length !== b.length)
|
|
163
|
+
return false;
|
|
164
|
+
const bufA = Buffer.from(a);
|
|
165
|
+
const bufB = Buffer.from(b);
|
|
166
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
167
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawchatsai/connector",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.16",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ClawChats OpenClaw plugin — P2P tunnel + local API bridge",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"better-sqlite3": ">=9.0.0",
|
|
28
|
+
"jose": "^5.10.0",
|
|
28
29
|
"werift": "^0.19.9",
|
|
29
30
|
"ws": "^8.0.0"
|
|
30
31
|
},
|