@stage-labs/metro 0.1.0-beta.53 → 0.1.0-beta.54
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/package.json +1 -1
- package/runtime/node_modules/@metro-labs/mcp/package.json +1 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-api.ts +5 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/agent-connector-api.ts +9 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/api-http.ts +6 -13
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/attach-serve.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/boot.ts +0 -4
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/bundle-api.ts +5 -6
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/claude-api.ts +3 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-api.ts +5 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/connector-oauth.ts +1 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/http.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-key.ts +33 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-registry.ts +13 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/identity-routes.ts +54 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-mode.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/{session-config.ts → return-to.ts} +0 -1
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-api.ts +9 -7
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session-apis.ts +2 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/signed-identity.ts +36 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/update-api.ts +2 -5
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/upload-api.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/vault-api.ts +2 -32
- package/runtime/node_modules/@metro-labs/mcp/src/db/file-admin.ts +0 -10
- package/runtime/node_modules/@metro-labs/mcp/src/db/schema.ts +0 -6
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/index.ts +1 -2
- package/runtime/node_modules/@metro-labs/mcp/src/mcp/request-identity.ts +2 -28
- package/runtime/node_modules/@metro-labs/mcp/src/monitor/api.ts +2 -5
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +1 -0
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/local-secret.ts +0 -21
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/session.ts +0 -96
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-auth.ts +0 -73
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-nonces.ts +0 -43
- package/runtime/node_modules/@metro-labs/mcp/src/daemon/siwe-routes.ts +0 -86
- package/runtime/node_modules/@metro-labs/mcp/src/db/users.ts +0 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.54",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -259,20 +259,20 @@ function methodAllowed(tgt: Routable, method: string | undefined): boolean {
|
|
|
259
259
|
return ALLOWED[tgt.kind].includes(method ?? '');
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
function dispatch(
|
|
262
|
+
async function dispatch(
|
|
263
263
|
req: IncomingMessage,
|
|
264
264
|
res: ServerResponse,
|
|
265
265
|
deps: AgentApiDeps,
|
|
266
266
|
tgt: Routable,
|
|
267
267
|
): Promise<void> {
|
|
268
|
-
const session = apiSession(req);
|
|
268
|
+
const session = await apiSession(req);
|
|
269
269
|
if (!session) {
|
|
270
270
|
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
271
|
-
return
|
|
271
|
+
return;
|
|
272
272
|
}
|
|
273
273
|
if (tgt.kind === 'accounts')
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
await handleAccountRoute(req, res, deps, session, tgt.id, tgt.route);
|
|
275
|
+
else await routeAgent(req, res, deps, session, tgt);
|
|
276
276
|
}
|
|
277
277
|
|
|
278
278
|
export function handleAgentApiRequest(
|
|
@@ -122,12 +122,15 @@ export function handleAgentConnectorRequest(
|
|
|
122
122
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
123
123
|
return true;
|
|
124
124
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
125
|
+
apiSession(req)
|
|
126
|
+
.then((session) => {
|
|
127
|
+
if (!session) {
|
|
128
|
+
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
return route(req, res, deps, session, tgt);
|
|
132
|
+
})
|
|
133
|
+
.catch((err: unknown) => {
|
|
131
134
|
log.warn({ err: errMsg(err) }, 'agent-api: unhandled error');
|
|
132
135
|
if (!res.headersSent) sendJson(req, res, 500, { error: 'agent api failed' });
|
|
133
136
|
});
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { ApiError } from './api-error.js';
|
|
3
3
|
import { errMsg, log } from './log.js';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { signedIdentity } from './signed-identity.js';
|
|
5
|
+
import { identitySubject } from './identity-registry.js';
|
|
6
6
|
|
|
7
7
|
const BODY_MAX = 4 * 1024;
|
|
8
8
|
|
|
@@ -35,17 +35,10 @@ export function sendJson(
|
|
|
35
35
|
res.end(JSON.stringify(body));
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export function apiSession(req: IncomingMessage): ApiSession | null {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (token === '') return null;
|
|
43
|
-
try {
|
|
44
|
-
const { subject } = verifySession(token, secret);
|
|
45
|
-
return { subject: subject.toLowerCase() };
|
|
46
|
-
} catch {
|
|
47
|
-
return null;
|
|
48
|
-
}
|
|
38
|
+
export async function apiSession(req: IncomingMessage): Promise<ApiSession | null> {
|
|
39
|
+
const address = await signedIdentity(req);
|
|
40
|
+
const subject = address === null ? undefined : identitySubject(address);
|
|
41
|
+
return subject === undefined ? null : { subject };
|
|
49
42
|
}
|
|
50
43
|
|
|
51
44
|
export interface AgentIdentity {
|
|
@@ -7,7 +7,6 @@ import {
|
|
|
7
7
|
} from '../stations/attachments.js';
|
|
8
8
|
import {
|
|
9
9
|
allowedAgents,
|
|
10
|
-
authConfigFromEnv,
|
|
11
10
|
authenticate,
|
|
12
11
|
extractToken,
|
|
13
12
|
} from '../mcp/request-identity.js';
|
|
@@ -21,7 +20,7 @@ function authorized(req: IncomingMessage, name: string): boolean {
|
|
|
21
20
|
const owner = attachmentOwner(name);
|
|
22
21
|
if (owner === undefined) return false;
|
|
23
22
|
if (grantAllows(name, owner, extractToken(req) ?? '')) return true;
|
|
24
|
-
return allowedAgents(authenticate(req
|
|
23
|
+
return allowedAgents(authenticate(req) ?? undefined).has(
|
|
25
24
|
owner,
|
|
26
25
|
);
|
|
27
26
|
}
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
} from './http.js';
|
|
21
21
|
import { localAgentKey } from '../db/materialize.js';
|
|
22
22
|
import { fileSource } from '../db/file-source.js';
|
|
23
|
-
import { ensureLocalSessionSecret } from './local-secret.js';
|
|
24
23
|
import { applyLocalOwner } from './local-owner.js';
|
|
25
24
|
import { localOwner } from '../db/file-admin.js';
|
|
26
25
|
import { ensureStationDeps } from './runtime-deps.js';
|
|
@@ -119,7 +118,6 @@ async function sessionApis(): Promise<SessionApis> {
|
|
|
119
118
|
prepareAccount,
|
|
120
119
|
});
|
|
121
120
|
const vault = await import('../db/vault.js');
|
|
122
|
-
const users = await import('../db/users.js');
|
|
123
121
|
return {
|
|
124
122
|
vaultApi: {
|
|
125
123
|
list: vault.listVaultForOwner,
|
|
@@ -127,14 +125,12 @@ async function sessionApis(): Promise<SessionApis> {
|
|
|
127
125
|
get: vault.getVaultForOwner,
|
|
128
126
|
remove: vault.deleteVaultForOwner,
|
|
129
127
|
},
|
|
130
|
-
siwe: { ensureUser: users.ensureUserByAddress },
|
|
131
128
|
mode: hostedMode,
|
|
132
129
|
};
|
|
133
130
|
}
|
|
134
131
|
|
|
135
132
|
async function main(): Promise<void> {
|
|
136
133
|
if (isLocalMode()) {
|
|
137
|
-
ensureLocalSessionSecret();
|
|
138
134
|
applyLocalOwner();
|
|
139
135
|
await materializeFrom(fileSource, { allowEmpty: true });
|
|
140
136
|
}
|
|
@@ -92,12 +92,11 @@ export function handleBundleRequest(req: IncomingMessage, res: ServerResponse, d
|
|
|
92
92
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
93
93
|
return true;
|
|
94
94
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
answer(req, deps, session.subject, path)
|
|
95
|
+
apiSession(req)
|
|
96
|
+
.then((session) => {
|
|
97
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
98
|
+
return answer(req, deps, session.subject, path);
|
|
99
|
+
})
|
|
101
100
|
.then((body) => {
|
|
102
101
|
sendJson(req, res, isRestore ? 201 : 200, body);
|
|
103
102
|
})
|
|
@@ -77,13 +77,9 @@ export function handleClaudeRequest(
|
|
|
77
77
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
78
78
|
return true;
|
|
79
79
|
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return true;
|
|
84
|
-
}
|
|
85
|
-
Promise.resolve()
|
|
86
|
-
.then(() => {
|
|
80
|
+
apiSession(req)
|
|
81
|
+
.then((session) => {
|
|
82
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
87
83
|
deps.authorize(session.subject);
|
|
88
84
|
return answer(req.method ?? 'GET', path, new URLSearchParams(search), (deps.dir ?? claudeDir)());
|
|
89
85
|
})
|
|
@@ -265,7 +265,7 @@ const ALLOWED: Record<Routable['kind'], string[]> = {
|
|
|
265
265
|
rename: ['POST'],
|
|
266
266
|
};
|
|
267
267
|
|
|
268
|
-
function dispatch(
|
|
268
|
+
async function dispatch(
|
|
269
269
|
req: IncomingMessage,
|
|
270
270
|
res: ServerResponse,
|
|
271
271
|
deps: ConnectorApiDeps,
|
|
@@ -273,14 +273,14 @@ function dispatch(
|
|
|
273
273
|
): Promise<void> {
|
|
274
274
|
if (tgt.kind === 'callback') {
|
|
275
275
|
handleCallback(req, res, deps);
|
|
276
|
-
return
|
|
276
|
+
return;
|
|
277
277
|
}
|
|
278
|
-
const session = apiSession(req);
|
|
278
|
+
const session = await apiSession(req);
|
|
279
279
|
if (!session) {
|
|
280
280
|
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
281
|
-
return
|
|
281
|
+
return;
|
|
282
282
|
}
|
|
283
|
-
|
|
283
|
+
await route(req, res, deps, session, tgt);
|
|
284
284
|
}
|
|
285
285
|
|
|
286
286
|
export function handleConnectorApiRequest(
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
} from './tunnel.js';
|
|
28
28
|
import { attachmentEventUrl, handleAttachRequest } from './attach-serve.js';
|
|
29
29
|
import { webhookEntry } from '@metro-labs/webhook';
|
|
30
|
-
import {
|
|
30
|
+
import { handleIdentityRequest } from './identity-routes.js';
|
|
31
31
|
import { handleModeRequest } from './mode-api.js';
|
|
32
32
|
import { handleUploadRequest } from './upload-api.js';
|
|
33
33
|
import {
|
|
@@ -328,7 +328,7 @@ function handleSignInRoutes(
|
|
|
328
328
|
res: ServerResponse,
|
|
329
329
|
apis: SessionApis,
|
|
330
330
|
): boolean {
|
|
331
|
-
if (apis.
|
|
331
|
+
if (apis.identity !== undefined && handleIdentityRequest(req, res, apis.identity)) return true;
|
|
332
332
|
return apis.mode !== undefined && handleModeRequest(req, res, apis.mode);
|
|
333
333
|
}
|
|
334
334
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { hkdfSync } from 'node:crypto';
|
|
2
|
+
import { secp256k1 } from '@noble/curves/secp256k1';
|
|
3
|
+
import { mapHashToField } from '@noble/curves/abstract/modular';
|
|
4
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
5
|
+
|
|
6
|
+
export const ENCRYPTION_KEY_TYPED_DATA = {
|
|
7
|
+
domain: { name: 'metro', version: '1' },
|
|
8
|
+
types: {
|
|
9
|
+
EncryptionKey: [
|
|
10
|
+
{ name: 'purpose', type: 'string' },
|
|
11
|
+
{ name: 'keyVersion', type: 'uint256' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
primaryType: 'EncryptionKey',
|
|
15
|
+
message: { purpose: 'encryption-key', keyVersion: 1n },
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const KEY_SALT = 'metro';
|
|
19
|
+
const IDENTITY_INFO = 'vault-secp256k1';
|
|
20
|
+
const KEY_HASH_BYTES = 48;
|
|
21
|
+
|
|
22
|
+
const toHex = (bytes: Uint8Array): `0x${string}` =>
|
|
23
|
+
`0x${[...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')}`;
|
|
24
|
+
|
|
25
|
+
export function deriveIdentityKey(signature: `0x${string}`): `0x${string}` {
|
|
26
|
+
const ikm = Buffer.from(signature.slice(2), 'hex');
|
|
27
|
+
const hash = new Uint8Array(hkdfSync('sha256', ikm, KEY_SALT, IDENTITY_INFO, KEY_HASH_BYTES));
|
|
28
|
+
return toHex(mapHashToField(hash, secp256k1.CURVE.n));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function deriveIdentityAddress(signature: `0x${string}`): string {
|
|
32
|
+
return privateKeyToAccount(deriveIdentityKey(signature)).address.toLowerCase();
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const subjects = new Map<string, string>();
|
|
2
|
+
|
|
3
|
+
export function authorizeIdentity(address: string, subject: string): void {
|
|
4
|
+
subjects.set(address.toLowerCase(), subject.toLowerCase());
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function identitySubject(address: string): string | undefined {
|
|
8
|
+
return subjects.get(address.toLowerCase());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resetIdentities(): void {
|
|
12
|
+
subjects.clear();
|
|
13
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { verifyTypedData } from 'viem';
|
|
3
|
+
import { log } from './log.js';
|
|
4
|
+
import { ApiError } from './api-error.js';
|
|
5
|
+
import { apiFailure, bodyField, cors, readJsonBody, sendJson } from './api-http.js';
|
|
6
|
+
import { ENCRYPTION_KEY_TYPED_DATA, deriveIdentityAddress } from './identity-key.js';
|
|
7
|
+
import { authorizeIdentity } from './identity-registry.js';
|
|
8
|
+
|
|
9
|
+
const PATH = '/auth/identity';
|
|
10
|
+
const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
|
|
11
|
+
|
|
12
|
+
export interface IdentityRouteDeps {
|
|
13
|
+
owner: () => string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function register(req: IncomingMessage, deps: IdentityRouteDeps): Promise<{ address: string; owner: string }> {
|
|
17
|
+
const signature = bodyField(await readJsonBody(req), 'signature');
|
|
18
|
+
if (typeof signature !== 'string' || !SIGNATURE_RE.test(signature))
|
|
19
|
+
throw new ApiError('signature is required: the EncryptionKey signature of the owner wallet', 400);
|
|
20
|
+
const owner = deps.owner();
|
|
21
|
+
if (owner === null)
|
|
22
|
+
throw new ApiError('this machine has no owner yet: start it with metro serve --owner <address>', 403);
|
|
23
|
+
const ok = await verifyTypedData({
|
|
24
|
+
...ENCRYPTION_KEY_TYPED_DATA,
|
|
25
|
+
address: owner as `0x${string}`,
|
|
26
|
+
signature: signature as `0x${string}`,
|
|
27
|
+
}).catch(() => false);
|
|
28
|
+
if (!ok) throw new ApiError('this machine belongs to another wallet', 403);
|
|
29
|
+
const address = deriveIdentityAddress(signature as `0x${string}`);
|
|
30
|
+
authorizeIdentity(address, owner);
|
|
31
|
+
log.info({ owner, identity: address }, 'identity: owner signed in');
|
|
32
|
+
return { address, owner };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function handleIdentityRequest(req: IncomingMessage, res: ServerResponse, deps: IdentityRouteDeps): boolean {
|
|
36
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
37
|
+
if (path !== PATH) return false;
|
|
38
|
+
if (req.method === 'OPTIONS') {
|
|
39
|
+
res.writeHead(204, cors(req)).end();
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
if (req.method !== 'POST') {
|
|
43
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
register(req, deps)
|
|
47
|
+
.then((body) => {
|
|
48
|
+
sendJson(req, res, 200, body);
|
|
49
|
+
})
|
|
50
|
+
.catch((err: unknown) => {
|
|
51
|
+
apiFailure(req, res, err, 'identity');
|
|
52
|
+
});
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
@@ -44,7 +44,6 @@ import {
|
|
|
44
44
|
localOwnedAgentOrThrow,
|
|
45
45
|
localOwner,
|
|
46
46
|
localResetAgentKey,
|
|
47
|
-
ownerSignIn,
|
|
48
47
|
readLocalAgentFile,
|
|
49
48
|
} from '../db/file-admin.js';
|
|
50
49
|
import { listAgentFiles, readAgentFile } from '../db/file-source.js';
|
|
@@ -191,7 +190,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
191
190
|
localCli,
|
|
192
191
|
claudeApi: { authorize: (subject) => { assertLocalOwner(subject); } },
|
|
193
192
|
updateApi: { authorize: (subject) => { assertLocalOwner(subject); }, restart: deps.restart },
|
|
194
|
-
|
|
193
|
+
identity: { owner: localOwner },
|
|
195
194
|
mode: localModeInfo,
|
|
196
195
|
};
|
|
197
196
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { apiSession, cors, sendJson } from './api-http.js';
|
|
2
|
+
import { apiFailure, apiSession, cors, sendJson } from './api-http.js';
|
|
3
3
|
|
|
4
4
|
const PATH = '/api/session';
|
|
5
5
|
|
|
@@ -17,11 +17,13 @@ export function handleSessionApiRequest(
|
|
|
17
17
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
18
18
|
return true;
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
apiSession(req)
|
|
21
|
+
.then((session) => {
|
|
22
|
+
if (!session) sendJson(req, res, 401, { error: 'unauthorized' });
|
|
23
|
+
else sendJson(req, res, 200, { subject: session.subject });
|
|
24
|
+
})
|
|
25
|
+
.catch((err: unknown) => {
|
|
26
|
+
apiFailure(req, res, err, 'session');
|
|
27
|
+
});
|
|
26
28
|
return true;
|
|
27
29
|
}
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
handleAgentConnectorRequest,
|
|
11
11
|
type AgentConnectorApiDeps,
|
|
12
12
|
} from './agent-connector-api.js';
|
|
13
|
-
import type {
|
|
13
|
+
import type { IdentityRouteDeps } from './identity-routes.js';
|
|
14
14
|
import { handleLocalCliRequest, type LocalCliDeps } from './local-cli-api.js';
|
|
15
15
|
import { handleClaudeRequest, type ClaudeApiDeps } from './claude-api.js';
|
|
16
16
|
import type { ModeInfo } from './mode-api.js';
|
|
@@ -28,7 +28,7 @@ export interface SessionApis {
|
|
|
28
28
|
claudeApi?: ClaudeApiDeps;
|
|
29
29
|
connectorApi?: ConnectorApiDeps;
|
|
30
30
|
relayApi?: RelayApiDeps;
|
|
31
|
-
|
|
31
|
+
identity?: IdentityRouteDeps;
|
|
32
32
|
mode?: () => ModeInfo;
|
|
33
33
|
}
|
|
34
34
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { verifyMessage } from 'viem';
|
|
3
|
+
import { normalizeAddress } from '../db/address.js';
|
|
4
|
+
|
|
5
|
+
export const AUTH_SCHEME = 'Metro';
|
|
6
|
+
const SKEW_MS = 5 * 60_000;
|
|
7
|
+
const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
|
|
8
|
+
|
|
9
|
+
export const identityChallenge = (method: string, path: string, at: number): string =>
|
|
10
|
+
`metro-auth\n${method} ${path}\n${String(at)}`;
|
|
11
|
+
|
|
12
|
+
export interface SignedHeader {
|
|
13
|
+
address: string;
|
|
14
|
+
at: number;
|
|
15
|
+
signature: `0x${string}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseIdentityHeader(header: string): SignedHeader | null {
|
|
19
|
+
const [scheme, rawAddress, rawAt, signature] = header.trim().split(/\s+/);
|
|
20
|
+
if (scheme !== AUTH_SCHEME || rawAddress === undefined || rawAt === undefined || signature === undefined) return null;
|
|
21
|
+
const address = normalizeAddress(rawAddress);
|
|
22
|
+
const at = Number(rawAt);
|
|
23
|
+
if (address === null || !Number.isFinite(at) || !SIGNATURE_RE.test(signature)) return null;
|
|
24
|
+
return { address, at, signature: signature as `0x${string}` };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function signedIdentity(req: IncomingMessage, now = Date.now()): Promise<string | null> {
|
|
28
|
+
const proof = parseIdentityHeader(req.headers.authorization ?? '');
|
|
29
|
+
if (proof === null || Math.abs(now - proof.at) > SKEW_MS) return null;
|
|
30
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
31
|
+
const message = identityChallenge(req.method ?? '', path, proof.at);
|
|
32
|
+
const ok = await verifyMessage({ address: proof.address as `0x${string}`, message, signature: proof.signature }).catch(
|
|
33
|
+
() => false,
|
|
34
|
+
);
|
|
35
|
+
return ok ? proof.address : null;
|
|
36
|
+
}
|
|
@@ -93,13 +93,10 @@ export function handleUpdateRequest(req: IncomingMessage, res: ServerResponse, d
|
|
|
93
93
|
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
94
94
|
return true;
|
|
95
95
|
}
|
|
96
|
-
const session = apiSession(req);
|
|
97
|
-
if (!session) {
|
|
98
|
-
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
96
|
Promise.resolve()
|
|
102
97
|
.then(async (): Promise<unknown> => {
|
|
98
|
+
const session = await apiSession(req);
|
|
99
|
+
if (!session) throw new ApiError('unauthorized', 401);
|
|
103
100
|
deps.authorize(session.subject);
|
|
104
101
|
const bin = binOrThrow(deps);
|
|
105
102
|
if (req.method === 'GET') return { ...(await check(bin)), running: METRO_VERSION };
|
|
@@ -4,7 +4,6 @@ import { guessMime } from '../stations/attachments.js';
|
|
|
4
4
|
import { safeFileName } from '../stations/attach-inline.js';
|
|
5
5
|
import {
|
|
6
6
|
allowedAgents,
|
|
7
|
-
authConfigFromEnv,
|
|
8
7
|
authenticate,
|
|
9
8
|
extractToken,
|
|
10
9
|
} from '../mcp/request-identity.js';
|
|
@@ -69,7 +68,7 @@ const query = (req: IncomingMessage, key: string): string | undefined =>
|
|
|
69
68
|
new URL(req.url ?? '/', 'http://localhost').searchParams.get(key) ?? undefined;
|
|
70
69
|
|
|
71
70
|
const identityScope = (req: IncomingMessage): Set<string> =>
|
|
72
|
-
allowedAgents(authenticate(req
|
|
71
|
+
allowedAgents(authenticate(req) ?? undefined);
|
|
73
72
|
|
|
74
73
|
function ownerFromScope(req: IncomingMessage, allowed: Set<string>): string {
|
|
75
74
|
const requested = query(req, 'agent');
|
|
@@ -1,42 +1,12 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { errMsg, log } from './log.js';
|
|
3
|
-
import { verifyMessage } from 'viem';
|
|
4
3
|
import { apiFailure, cors, readJsonBody, sendJson } from './api-http.js';
|
|
5
|
-
import { normalizeAddress } from '../db/address.js';
|
|
6
4
|
import { parseId } from '../db/ids.js';
|
|
7
5
|
import { ENVELOPE_MAX, type VaultBundle, type VaultEntry } from './vault-types.js';
|
|
6
|
+
import { signedIdentity } from './signed-identity.js';
|
|
8
7
|
|
|
9
8
|
const PREFIX = '/api/vault';
|
|
10
9
|
const BODY_MAX = ENVELOPE_MAX + 64 * 1024;
|
|
11
|
-
const SKEW_MS = 5 * 60_000;
|
|
12
|
-
const SIGNATURE_RE = /^0x[0-9a-fA-F]{130}$/;
|
|
13
|
-
|
|
14
|
-
export const vaultChallenge = (method: string, path: string, at: number): string =>
|
|
15
|
-
`metro-vault\n${method} ${path}\n${String(at)}`;
|
|
16
|
-
|
|
17
|
-
interface VaultProof {
|
|
18
|
-
address: string;
|
|
19
|
-
at: number;
|
|
20
|
-
signature: `0x${string}`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function parseVaultHeader(header: string): VaultProof | null {
|
|
24
|
-
const [scheme, rawAddress, rawAt, signature] = header.trim().split(/\s+/);
|
|
25
|
-
if (scheme !== 'Vault' || rawAddress === undefined || rawAt === undefined || signature === undefined) return null;
|
|
26
|
-
const address = normalizeAddress(rawAddress);
|
|
27
|
-
const at = Number(rawAt);
|
|
28
|
-
if (address === null || !Number.isFinite(at) || !SIGNATURE_RE.test(signature)) return null;
|
|
29
|
-
return { address, at, signature: signature as `0x${string}` };
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function vaultIdentity(req: IncomingMessage, now = Date.now()): Promise<string | null> {
|
|
33
|
-
const proof = parseVaultHeader(req.headers.authorization ?? '');
|
|
34
|
-
if (proof === null || Math.abs(now - proof.at) > SKEW_MS) return null;
|
|
35
|
-
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
36
|
-
const message = vaultChallenge(req.method ?? '', path, proof.at);
|
|
37
|
-
const ok = await verifyMessage({ address: proof.address as `0x${string}`, message, signature: proof.signature }).catch(() => false);
|
|
38
|
-
return ok ? proof.address : null;
|
|
39
|
-
}
|
|
40
10
|
|
|
41
11
|
export interface VaultApiDeps {
|
|
42
12
|
list: (subject: string) => Promise<VaultEntry[]>;
|
|
@@ -64,7 +34,7 @@ async function route(
|
|
|
64
34
|
tgt: { kind: 'index' } | { kind: 'bundle'; id: string },
|
|
65
35
|
): Promise<void> {
|
|
66
36
|
try {
|
|
67
|
-
const owner = await
|
|
37
|
+
const owner = await signedIdentity(req);
|
|
68
38
|
if (owner === null) {
|
|
69
39
|
sendJson(req, res, 401, { error: 'unauthorized' });
|
|
70
40
|
return;
|
|
@@ -54,16 +54,6 @@ export function setLocalOwner(raw: string, dir = agentsDir()): string {
|
|
|
54
54
|
return address;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
export function ownerSignIn(raw: string, dir = agentsDir()): Promise<string> {
|
|
58
|
-
const owner = localOwner(dir);
|
|
59
|
-
if (owner === null)
|
|
60
|
-
return Promise.reject(
|
|
61
|
-
new ApiError('this machine has no owner yet: start it with metro serve --owner <address>', 403),
|
|
62
|
-
);
|
|
63
|
-
if (owner !== normalizeAddress(raw))
|
|
64
|
-
return Promise.reject(new ApiError('this machine belongs to another wallet', 403));
|
|
65
|
-
return Promise.resolve(owner);
|
|
66
|
-
}
|
|
67
57
|
|
|
68
58
|
function isOwner(subject: string, dir: string): boolean {
|
|
69
59
|
const owner = localOwner(dir);
|
|
@@ -4,12 +4,6 @@ import { STATIONS, type ConnectorTransport, type StationName } from './stations.
|
|
|
4
4
|
|
|
5
5
|
export { STATIONS, type ConnectorTransport, type StationName };
|
|
6
6
|
|
|
7
|
-
export const users = pgTable('users', {
|
|
8
|
-
id: text('id').primaryKey(),
|
|
9
|
-
email: text('email').unique(),
|
|
10
|
-
address: text('address').unique(),
|
|
11
|
-
});
|
|
12
|
-
|
|
13
7
|
export type ProjectRole = 'admin' | 'member';
|
|
14
8
|
|
|
15
9
|
|
|
@@ -5,7 +5,6 @@ import {
|
|
|
5
5
|
} from '../daemon/http.js';
|
|
6
6
|
import {
|
|
7
7
|
allowedAgents,
|
|
8
|
-
authConfigFromEnv,
|
|
9
8
|
authenticate,
|
|
10
9
|
runWithIdentity,
|
|
11
10
|
type RequestIdentity,
|
|
@@ -156,7 +155,7 @@ export async function createMetroMcp(): Promise<{
|
|
|
156
155
|
req: IncomingMessage,
|
|
157
156
|
res: ServerResponse,
|
|
158
157
|
): Promise<void> => {
|
|
159
|
-
const identity = authenticate(req
|
|
158
|
+
const identity = authenticate(req);
|
|
160
159
|
if (!identity) {
|
|
161
160
|
res.writeHead(401).end('unauthorized');
|
|
162
161
|
return;
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import type { IncomingMessage } from 'node:http';
|
|
3
|
-
import { verifySession } from '../daemon/session.js';
|
|
4
3
|
import { agentIdForKey } from '../db/key-map.js';
|
|
5
4
|
|
|
6
5
|
export type RequestIdentity =
|
|
@@ -20,14 +19,6 @@ export function currentIdentity(): RequestIdentity | undefined {
|
|
|
20
19
|
return storage.getStore();
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
export interface AuthConfig {
|
|
24
|
-
sessionSecret: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function authConfigFromEnv(): AuthConfig {
|
|
28
|
-
return { sessionSecret: process.env.METRO_SESSION_SECRET?.trim() ?? '' };
|
|
29
|
-
}
|
|
30
|
-
|
|
31
22
|
export function extractToken(req: IncomingMessage): string | undefined {
|
|
32
23
|
const header = req.headers.authorization;
|
|
33
24
|
if (header?.startsWith('Bearer ')) {
|
|
@@ -40,28 +31,11 @@ export function extractToken(req: IncomingMessage): string | undefined {
|
|
|
40
31
|
return qt ?? undefined;
|
|
41
32
|
}
|
|
42
33
|
|
|
43
|
-
|
|
44
|
-
/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token);
|
|
45
|
-
|
|
46
|
-
export function authenticate(
|
|
47
|
-
req: IncomingMessage,
|
|
48
|
-
cfg: AuthConfig,
|
|
49
|
-
): RequestIdentity | null {
|
|
34
|
+
export function authenticate(req: IncomingMessage): RequestIdentity | null {
|
|
50
35
|
const token = extractToken(req);
|
|
51
36
|
if (!token) return null;
|
|
52
|
-
|
|
53
37
|
const agentId = agentIdForKey(token);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (cfg.sessionSecret !== '' && looksLikeJwt(token)) {
|
|
57
|
-
try {
|
|
58
|
-
const { subject, agentIds } = verifySession(token, cfg.sessionSecret);
|
|
59
|
-
return { kind: 'session', subject, agentIds };
|
|
60
|
-
} catch {
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return null;
|
|
38
|
+
return agentId === undefined ? null : { kind: 'agent', agentId };
|
|
65
39
|
}
|
|
66
40
|
|
|
67
41
|
export function allowedAgents(
|
|
@@ -6,7 +6,6 @@ import { callTargetDenied, eventInScope } from '../db/agent-scope.js';
|
|
|
6
6
|
import { hasAnyKey } from '../db/key-map.js';
|
|
7
7
|
import {
|
|
8
8
|
allowedAgents,
|
|
9
|
-
authConfigFromEnv,
|
|
10
9
|
authenticate,
|
|
11
10
|
} from '../mcp/request-identity.js';
|
|
12
11
|
import { METRO_VERSION } from '../daemon/version.js';
|
|
@@ -20,9 +19,7 @@ export type MonitorCall = (
|
|
|
20
19
|
const KEEPALIVE_MS = 25_000;
|
|
21
20
|
const CALL_BODY_MAX = 256 * 1024;
|
|
22
21
|
|
|
23
|
-
|
|
24
|
-
return hasAnyKey() || authConfigFromEnv().sessionSecret !== '';
|
|
25
|
-
}
|
|
22
|
+
const monitorEnabled = (): boolean => hasAnyKey();
|
|
26
23
|
|
|
27
24
|
function cors(req: IncomingMessage): Record<string, string> {
|
|
28
25
|
return {
|
|
@@ -230,7 +227,7 @@ export function handleMonitorRequest(
|
|
|
230
227
|
const path = (req.url ?? '').split('?', 2)[0] ?? '';
|
|
231
228
|
if (!path.startsWith('/api/')) return false;
|
|
232
229
|
if (preflight(req, res, path)) return true;
|
|
233
|
-
const identity = authenticate(req
|
|
230
|
+
const identity = authenticate(req);
|
|
234
231
|
if (!identity) {
|
|
235
232
|
sendJson(res, req, 401, { error: 'unauthorized' });
|
|
236
233
|
return true;
|
package/runtime/runtime.json
CHANGED
package/runtime/stations.json
CHANGED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { agentsDir } from '../db/file-source.js';
|
|
5
|
-
import { ensureSecureDir, writeSecure } from './secure-fs.js';
|
|
6
|
-
|
|
7
|
-
const SECRET_FILE = '.session-secret';
|
|
8
|
-
|
|
9
|
-
export function ensureLocalSessionSecret(dir = agentsDir()): string {
|
|
10
|
-
const configured = process.env.METRO_SESSION_SECRET?.trim() ?? '';
|
|
11
|
-
if (configured !== '') return configured;
|
|
12
|
-
const path = join(dir, SECRET_FILE);
|
|
13
|
-
let secret = existsSync(path) ? readFileSync(path, 'utf8').trim() : '';
|
|
14
|
-
if (secret === '') {
|
|
15
|
-
secret = randomBytes(32).toString('base64url');
|
|
16
|
-
ensureSecureDir(dir);
|
|
17
|
-
writeSecure(path, `${secret}\n`);
|
|
18
|
-
}
|
|
19
|
-
process.env.METRO_SESSION_SECRET = secret;
|
|
20
|
-
return secret;
|
|
21
|
-
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
-
import { ID_RE } from '../db/ids.js';
|
|
3
|
-
|
|
4
|
-
export class SessionError extends Error {}
|
|
5
|
-
|
|
6
|
-
const b64url = (buf: Buffer): string =>
|
|
7
|
-
buf
|
|
8
|
-
.toString('base64')
|
|
9
|
-
.replace(/\+/g, '-')
|
|
10
|
-
.replace(/\//g, '_')
|
|
11
|
-
.replace(/=+$/, '');
|
|
12
|
-
|
|
13
|
-
const b64urlJson = (v: unknown): string =>
|
|
14
|
-
b64url(Buffer.from(JSON.stringify(v)));
|
|
15
|
-
|
|
16
|
-
const decodeJson = (seg: string): unknown =>
|
|
17
|
-
JSON.parse(
|
|
18
|
-
Buffer.from(seg.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString(
|
|
19
|
-
'utf8',
|
|
20
|
-
),
|
|
21
|
-
);
|
|
22
|
-
|
|
23
|
-
const HEADER = b64urlJson({ alg: 'HS256', typ: 'JWT' });
|
|
24
|
-
|
|
25
|
-
function sign(payload: Record<string, unknown>, secret: string): string {
|
|
26
|
-
if (secret === '') throw new SessionError('missing signing secret');
|
|
27
|
-
const body = b64urlJson(payload);
|
|
28
|
-
const data = `${HEADER}.${body}`;
|
|
29
|
-
const sig = b64url(createHmac('sha256', secret).update(data).digest());
|
|
30
|
-
return `${data}.${sig}`;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function verify(token: string, secret: string): Record<string, unknown> {
|
|
34
|
-
if (secret === '') throw new SessionError('missing signing secret');
|
|
35
|
-
const parts = token.split('.');
|
|
36
|
-
if (parts.length !== 3) throw new SessionError('malformed token');
|
|
37
|
-
const data = `${parts[0]}.${parts[1]}`;
|
|
38
|
-
const expected = b64url(createHmac('sha256', secret).update(data).digest());
|
|
39
|
-
const a = Buffer.from(parts[2] ?? '');
|
|
40
|
-
const b = Buffer.from(expected);
|
|
41
|
-
if (a.length !== b.length || !timingSafeEqual(a, b))
|
|
42
|
-
throw new SessionError('bad signature');
|
|
43
|
-
let payload: unknown;
|
|
44
|
-
try {
|
|
45
|
-
payload = decodeJson(parts[1] ?? '');
|
|
46
|
-
} catch {
|
|
47
|
-
throw new SessionError('malformed payload');
|
|
48
|
-
}
|
|
49
|
-
if (typeof payload !== 'object' || payload === null)
|
|
50
|
-
throw new SessionError('malformed payload');
|
|
51
|
-
return payload as Record<string, unknown>;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const nowSec = (now?: number): number => Math.floor((now ?? Date.now()) / 1000);
|
|
55
|
-
|
|
56
|
-
interface SessionClaims {
|
|
57
|
-
subject: string;
|
|
58
|
-
agentIds: string[];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function signSession(
|
|
62
|
-
claims: SessionClaims,
|
|
63
|
-
secret: string,
|
|
64
|
-
opts: { ttlSec?: number; now?: number } = {},
|
|
65
|
-
): string {
|
|
66
|
-
const iat = nowSec(opts.now);
|
|
67
|
-
return sign(
|
|
68
|
-
{
|
|
69
|
-
typ: 'session',
|
|
70
|
-
sub: claims.subject,
|
|
71
|
-
agent_ids: claims.agentIds,
|
|
72
|
-
iat,
|
|
73
|
-
exp: iat + (opts.ttlSec ?? 30 * 24 * 3600),
|
|
74
|
-
},
|
|
75
|
-
secret,
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function verifySession(
|
|
80
|
-
token: string,
|
|
81
|
-
secret: string,
|
|
82
|
-
now?: number,
|
|
83
|
-
): SessionClaims {
|
|
84
|
-
const p = verify(token, secret);
|
|
85
|
-
if (p.typ !== 'session') throw new SessionError('wrong token type');
|
|
86
|
-
if (typeof p.exp !== 'number' || p.exp < nowSec(now))
|
|
87
|
-
throw new SessionError('session expired');
|
|
88
|
-
const ids = p.agent_ids;
|
|
89
|
-
if (
|
|
90
|
-
typeof p.sub !== 'string' ||
|
|
91
|
-
!Array.isArray(ids) ||
|
|
92
|
-
ids.some((a) => typeof a !== 'string' || !ID_RE.test(a))
|
|
93
|
-
)
|
|
94
|
-
throw new SessionError('malformed session');
|
|
95
|
-
return { subject: p.sub, agentIds: ids as string[] };
|
|
96
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import { verifyMessage, type Hex } from 'viem';
|
|
2
|
-
import { parseSiweMessage, validateSiweMessage } from 'viem/siwe';
|
|
3
|
-
import { ApiError } from './api-error.js';
|
|
4
|
-
import { allowedWebHost } from './session-config.js';
|
|
5
|
-
|
|
6
|
-
export class SiweError extends ApiError {}
|
|
7
|
-
|
|
8
|
-
export interface SiweLogin {
|
|
9
|
-
message: string;
|
|
10
|
-
signature: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface SiweDeps {
|
|
14
|
-
takeNonce: (nonce: string) => boolean;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const HEX_SIGNATURE = /^0x[0-9a-fA-F]{130}$/;
|
|
18
|
-
|
|
19
|
-
export function allowedSiweDomain(domain: string): boolean {
|
|
20
|
-
return allowedWebHost(domain.replace(/:\d+$/, ''));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function hostOf(uri: string): string | null {
|
|
24
|
-
try {
|
|
25
|
-
return new URL(uri).host;
|
|
26
|
-
} catch {
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function fieldsOrThrow(message: string): {
|
|
32
|
-
address: `0x${string}`;
|
|
33
|
-
domain: string;
|
|
34
|
-
nonce: string;
|
|
35
|
-
parsed: ReturnType<typeof parseSiweMessage>;
|
|
36
|
-
} {
|
|
37
|
-
const parsed = parseSiweMessage(message);
|
|
38
|
-
const { address, domain, nonce, uri, version } = parsed;
|
|
39
|
-
if (
|
|
40
|
-
address === undefined ||
|
|
41
|
-
domain === undefined ||
|
|
42
|
-
nonce === undefined ||
|
|
43
|
-
uri === undefined ||
|
|
44
|
-
version !== '1'
|
|
45
|
-
)
|
|
46
|
-
throw new SiweError('that is not a sign-in message', 400);
|
|
47
|
-
if (!allowedSiweDomain(domain))
|
|
48
|
-
throw new SiweError(`sign-in messages for ${domain} are not accepted here`, 400);
|
|
49
|
-
if (hostOf(uri) !== domain)
|
|
50
|
-
throw new SiweError('the sign-in message names a different site', 400);
|
|
51
|
-
return { address, domain, nonce, parsed };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function verifySiweLogin(
|
|
55
|
-
input: SiweLogin,
|
|
56
|
-
deps: SiweDeps,
|
|
57
|
-
): Promise<string> {
|
|
58
|
-
if (!HEX_SIGNATURE.test(input.signature))
|
|
59
|
-
throw new SiweError('that is not an Ethereum signature', 400);
|
|
60
|
-
const { address, domain, nonce, parsed } = fieldsOrThrow(input.message);
|
|
61
|
-
const time = new Date();
|
|
62
|
-
if (!validateSiweMessage({ message: parsed, domain, nonce, time }))
|
|
63
|
-
throw new SiweError('this sign-in message is expired or does not match', 400);
|
|
64
|
-
if (!deps.takeNonce(nonce))
|
|
65
|
-
throw new SiweError('that sign-in has expired or was already used', 400);
|
|
66
|
-
const signature = input.signature as Hex;
|
|
67
|
-
if (!(await verifyMessage({ address, message: input.message, signature })))
|
|
68
|
-
throw new SiweError(
|
|
69
|
-
'the signature does not match the address; smart-contract wallets are not supported yet',
|
|
70
|
-
401,
|
|
71
|
-
);
|
|
72
|
-
return address.toLowerCase();
|
|
73
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { generateSiweNonce } from 'viem/siwe';
|
|
2
|
-
|
|
3
|
-
const TTL_MS = 10 * 60_000;
|
|
4
|
-
const SWEEP_MS = 60_000;
|
|
5
|
-
const MAX_LIVE = 500;
|
|
6
|
-
|
|
7
|
-
const live = new Map<string, number>();
|
|
8
|
-
let sweeper: ReturnType<typeof setInterval> | null = null;
|
|
9
|
-
|
|
10
|
-
function sweep(now = Date.now()): void {
|
|
11
|
-
for (const [nonce, expiresAt] of live) if (expiresAt <= now) live.delete(nonce);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function ensureSweeper(): void {
|
|
15
|
-
if (sweeper !== null) return;
|
|
16
|
-
sweeper = setInterval(sweep, SWEEP_MS);
|
|
17
|
-
sweeper.unref?.();
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function mintNonce(now = Date.now()): string {
|
|
21
|
-
sweep(now);
|
|
22
|
-
while (live.size >= MAX_LIVE) {
|
|
23
|
-
const oldest = live.keys().next();
|
|
24
|
-
if (oldest.done) break;
|
|
25
|
-
live.delete(oldest.value);
|
|
26
|
-
}
|
|
27
|
-
const nonce = generateSiweNonce();
|
|
28
|
-
live.set(nonce, now + TTL_MS);
|
|
29
|
-
ensureSweeper();
|
|
30
|
-
return nonce;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function takeNonce(nonce: string, now = Date.now()): boolean {
|
|
34
|
-
const expiresAt = live.get(nonce);
|
|
35
|
-
if (expiresAt === undefined) return false;
|
|
36
|
-
live.delete(nonce);
|
|
37
|
-
return expiresAt > now;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function nonceCount(): number {
|
|
41
|
-
sweep();
|
|
42
|
-
return live.size;
|
|
43
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
-
import { log } from './log.js';
|
|
3
|
-
import {
|
|
4
|
-
apiFailure,
|
|
5
|
-
bodyField,
|
|
6
|
-
cors,
|
|
7
|
-
readJsonBody,
|
|
8
|
-
sendJson,
|
|
9
|
-
} from './api-http.js';
|
|
10
|
-
import { mintNonce, takeNonce } from './siwe-nonces.js';
|
|
11
|
-
import { verifySiweLogin } from './siwe-auth.js';
|
|
12
|
-
import { signSession } from './session.js';
|
|
13
|
-
import { SESSION_TTL_SEC } from './session-config.js';
|
|
14
|
-
|
|
15
|
-
const NONCE_PATH = '/auth/siwe/nonce';
|
|
16
|
-
const VERIFY_PATH = '/auth/siwe/verify';
|
|
17
|
-
|
|
18
|
-
export interface SiweRouteDeps {
|
|
19
|
-
ensureUser: (address: string) => Promise<string>;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function handleVerify(
|
|
23
|
-
req: IncomingMessage,
|
|
24
|
-
res: ServerResponse,
|
|
25
|
-
secret: string,
|
|
26
|
-
deps: SiweRouteDeps,
|
|
27
|
-
): Promise<void> {
|
|
28
|
-
const body = await readJsonBody(req);
|
|
29
|
-
const message = bodyField(body, 'message');
|
|
30
|
-
const signature = bodyField(body, 'signature');
|
|
31
|
-
if (typeof message !== 'string' || typeof signature !== 'string') {
|
|
32
|
-
sendJson(req, res, 400, { error: 'message and signature are required' });
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
const address = await verifySiweLogin({ message, signature }, { takeNonce });
|
|
36
|
-
const userId = await deps.ensureUser(address);
|
|
37
|
-
log.info({ userId, address }, 'siwe: session issued');
|
|
38
|
-
const session = signSession({ subject: address, agentIds: [] }, secret, {
|
|
39
|
-
ttlSec: SESSION_TTL_SEC,
|
|
40
|
-
});
|
|
41
|
-
sendJson(req, res, 200, { session, address });
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const ROUTES: Record<string, string> = {
|
|
45
|
-
[NONCE_PATH]: 'GET',
|
|
46
|
-
[VERIFY_PATH]: 'POST',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
function refused(
|
|
50
|
-
req: IncomingMessage,
|
|
51
|
-
res: ServerResponse,
|
|
52
|
-
path: string,
|
|
53
|
-
): boolean {
|
|
54
|
-
if (req.method === 'OPTIONS') {
|
|
55
|
-
res.writeHead(204, cors(req)).end();
|
|
56
|
-
return true;
|
|
57
|
-
}
|
|
58
|
-
if (req.method !== ROUTES[path]) {
|
|
59
|
-
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
60
|
-
return true;
|
|
61
|
-
}
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function handleSiweAuthRequest(
|
|
66
|
-
req: IncomingMessage,
|
|
67
|
-
res: ServerResponse,
|
|
68
|
-
deps: SiweRouteDeps,
|
|
69
|
-
): boolean {
|
|
70
|
-
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
71
|
-
if (ROUTES[path] === undefined) return false;
|
|
72
|
-
if (refused(req, res, path)) return true;
|
|
73
|
-
const secret = process.env.METRO_SESSION_SECRET?.trim() ?? '';
|
|
74
|
-
if (secret === '') {
|
|
75
|
-
sendJson(req, res, 503, { error: 'sign-in is not configured' });
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
if (path === NONCE_PATH) {
|
|
79
|
-
sendJson(req, res, 200, { nonce: mintNonce() });
|
|
80
|
-
return true;
|
|
81
|
-
}
|
|
82
|
-
handleVerify(req, res, secret, deps).catch((err: unknown) => {
|
|
83
|
-
apiFailure(req, res, err, 'siwe');
|
|
84
|
-
});
|
|
85
|
-
return true;
|
|
86
|
-
}
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { eq } from 'drizzle-orm';
|
|
2
|
-
import { ApiError } from '../daemon/api-error.js';
|
|
3
|
-
import { getDb } from './client.js';
|
|
4
|
-
import { newId } from './ids.js';
|
|
5
|
-
import { users } from './schema.js';
|
|
6
|
-
import { normalizeAddress } from './address.js';
|
|
7
|
-
|
|
8
|
-
const UNIQUE_VIOLATION = '23505';
|
|
9
|
-
|
|
10
|
-
export class UserError extends ApiError {}
|
|
11
|
-
|
|
12
|
-
const CAUSE_DEPTH = 5;
|
|
13
|
-
|
|
14
|
-
export function isUniqueViolation(err: unknown): boolean {
|
|
15
|
-
let current: unknown = err;
|
|
16
|
-
for (let depth = 0; depth < CAUSE_DEPTH; depth += 1) {
|
|
17
|
-
if (typeof current !== 'object' || current === null) return false;
|
|
18
|
-
const { code, cause } = current as { code?: unknown; cause?: unknown };
|
|
19
|
-
if (code === UNIQUE_VIOLATION) return true;
|
|
20
|
-
current = cause;
|
|
21
|
-
}
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
export async function ensureUserByAddress(raw: string): Promise<string> {
|
|
27
|
-
const address = normalizeAddress(raw);
|
|
28
|
-
if (address === null) throw new UserError('an Ethereum address is required', 400);
|
|
29
|
-
const db = getDb();
|
|
30
|
-
const inserted = await db
|
|
31
|
-
.insert(users)
|
|
32
|
-
.values({ id: newId(), address })
|
|
33
|
-
.onConflictDoNothing({ target: users.address })
|
|
34
|
-
.returning({ id: users.id });
|
|
35
|
-
const id = inserted[0]?.id;
|
|
36
|
-
if (id !== undefined) return id;
|
|
37
|
-
const rows = await db.select({ id: users.id }).from(users).where(eq(users.address, address));
|
|
38
|
-
const existing = rows[0]?.id;
|
|
39
|
-
if (existing === undefined) throw new UserError('user lookup returned no id', 500);
|
|
40
|
-
return existing;
|
|
41
|
-
}
|