mirror of
https://github.com/kind-0/nsecbunkerd.git
synced 2026-08-01 07:21:42 +00:00
386 lines
13 KiB
TypeScript
386 lines
13 KiB
TypeScript
import "websocket-polyfill";
|
|
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKRpcRequest, NDKRpcResponse, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk';
|
|
import { NDKNostrRpc } from '@nostr-dev-kit/ndk';
|
|
import { debug } from 'debug';
|
|
import { Key, KeyUser } from '../run';
|
|
import { allowAllRequestsFromKey } from '../lib/acl/index.js';
|
|
import prisma from '../../db';
|
|
import createAccount from './commands/create_account';
|
|
import ping from './commands/ping.js';
|
|
import createNewKey from './commands/create_new_key';
|
|
import createNewPolicy from './commands/create_new_policy';
|
|
import createNewToken from './commands/create_new_token';
|
|
import unlockKey from './commands/unlock_key';
|
|
import renameKeyUser from './commands/rename_key_user.js';
|
|
import revokeUser from './commands/revoke_user';
|
|
import fs from 'fs';
|
|
import { validateRequestFromAdmin } from './validations/request-from-admin';
|
|
import { dmUser } from '../../utils/dm-user';
|
|
import { IConfig, getCurrentConfig } from "../../config";
|
|
|
|
export type IAdminOpts = {
|
|
npubs: string[];
|
|
adminRelays: string[];
|
|
key: string;
|
|
}
|
|
|
|
// TODO: Move to configuration
|
|
const allowNewKeys = true;
|
|
|
|
/**
|
|
* This class represents the admin interface for the nsecbunker daemon.
|
|
*
|
|
* It provides an interface for a UI to manage the daemon over nostr.
|
|
*/
|
|
class AdminInterface {
|
|
private npubs: string[];
|
|
private ndk: NDK;
|
|
private signerUser?: NDKUser;
|
|
readonly rpc: NDKNostrRpc;
|
|
readonly configFile: string;
|
|
public getKeys?: () => Promise<Key[]>;
|
|
public getKeyUsers?: (req: NDKRpcRequest) => Promise<KeyUser[]>;
|
|
public unlockKey?: (keyName: string, passphrase: string) => Promise<boolean>;
|
|
public loadNsec?: (keyName: string, nsec: string) => void;
|
|
|
|
constructor(opts: IAdminOpts, configFile: string) {
|
|
this.configFile = configFile;
|
|
this.npubs = opts.npubs||[];
|
|
this.ndk = new NDK({
|
|
explicitRelayUrls: opts.adminRelays,
|
|
signer: new NDKPrivateKeySigner(opts.key),
|
|
});
|
|
this.ndk.signer?.user().then((user: NDKUser) => {
|
|
let connectionString = `bunker://${user.npub}`;
|
|
|
|
if (opts.adminRelays.length > 0) {
|
|
connectionString += '@' + encodeURIComponent(`${opts.adminRelays.join(',').replace(/wss:\/\//g, '')}`);
|
|
}
|
|
|
|
console.log(`\n\nnsecBunker connection string:\n\n${connectionString}\n\n`);
|
|
|
|
// write connection string to connection.txt
|
|
fs.writeFileSync('connection.txt', connectionString);
|
|
|
|
this.signerUser = user;
|
|
|
|
this.connect();
|
|
|
|
this.notifyAdminsOfNewConnection(connectionString);
|
|
});
|
|
|
|
this.rpc = new NDKNostrRpc(this.ndk, this.ndk.signer!, debug("ndk:rpc"));
|
|
}
|
|
|
|
public async config(): Promise<IConfig> {
|
|
return getCurrentConfig(this.configFile);
|
|
}
|
|
|
|
private async notifyAdminsOfNewConnection(connectionString: string) {
|
|
const blastrNdk = new NDK({
|
|
explicitRelayUrls: ['wss://blastr.f7z.xyz', 'wss://nostr.mutinywallet.com'],
|
|
signer: this.ndk.signer
|
|
});
|
|
await blastrNdk.connect(2500);
|
|
|
|
for (const npub of this.npubs||[]) {
|
|
dmUser(blastrNdk, npub, `nsecBunker has started; use ${connectionString} to connect to it and unlock your key(s)`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the npub of the admin interface.
|
|
*/
|
|
public async npub() {
|
|
return (await this.ndk.signer?.user())!.npub;
|
|
}
|
|
|
|
private connect() {
|
|
if (this.npubs.length <= 0) {
|
|
console.log(`❌ Admin interface not starting because no admin npubs were provided`);
|
|
return;
|
|
}
|
|
|
|
this.ndk.pool.on('relay:connect', () => console.log('✅ nsecBunker Admin Interface ready'));
|
|
this.ndk.pool.on('relay:disconnect', () => console.log('❌ admin disconnected'));
|
|
this.ndk.connect(2500).then(() => {
|
|
// connect for whitelisted admins
|
|
this.rpc.subscribe({
|
|
"kinds": [24134 as number],
|
|
"#p": [this.signerUser!.pubkey],
|
|
});
|
|
|
|
this.rpc.on('request', (req) => this.handleRequest(req));
|
|
}).catch((err) => {
|
|
console.log('❌ admin connection failed');
|
|
console.log(err);
|
|
});
|
|
}
|
|
|
|
private async handleRequest(req: NDKRpcRequest) {
|
|
console.log(`request coming in`, req);
|
|
try {
|
|
await this.validateRequest(req);
|
|
|
|
switch (req.method) {
|
|
case 'get_keys': await this.reqGetKeys(req); break;
|
|
case 'get_key_users': await this.reqGetKeyUsers(req); break;
|
|
case 'rename_key_user': await renameKeyUser(this, req); break;
|
|
case 'get_key_tokens': await this.reqGetKeyTokens(req); break;
|
|
case 'revoke_user': await revokeUser(this, req); break;
|
|
case 'create_new_key': await createNewKey(this, req); break;
|
|
case 'create_account': await createAccount(this, req); break;
|
|
case 'ping': await ping(this, req); break;
|
|
case 'unlock_key': await unlockKey(this, req); break;
|
|
case 'create_new_policy': await createNewPolicy(this, req); break;
|
|
case 'get_policies': await this.reqListPolicies(req); break;
|
|
case 'create_new_token': await createNewToken(this, req); break;
|
|
default:
|
|
console.log(`Unknown method ${req.method}`);
|
|
return this.rpc.sendResponse(
|
|
req.id,
|
|
req.pubkey,
|
|
JSON.stringify(['error', `Unknown method ${req.method}`]),
|
|
24134
|
|
);
|
|
}
|
|
} catch (err: any) {
|
|
console.error(`Error handling request ${req.method}: ${err.message}`, req.params);
|
|
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify(['error', err?.message]), 24134);
|
|
}
|
|
}
|
|
|
|
private async validateRequest(req: NDKRpcRequest): Promise<void> {
|
|
// if this request is of type create_account, allow it
|
|
if (req.method === 'create_account' && allowNewKeys) {
|
|
console.log(`allowing create_account request`);
|
|
return;
|
|
}
|
|
|
|
if (!await validateRequestFromAdmin(req, this.npubs)) {
|
|
throw new Error('You are not designated to administrate this bunker');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Command to list tokens
|
|
*/
|
|
private async reqGetKeyTokens(req: NDKRpcRequest) {
|
|
const keyName = req.params[0];
|
|
const tokens = await prisma.token.findMany({
|
|
where: { keyName },
|
|
include: {
|
|
policy: {
|
|
include: {
|
|
rules: true,
|
|
},
|
|
},
|
|
KeyUser: true,
|
|
},
|
|
});
|
|
|
|
const keys = await this.getKeys!();
|
|
const key = keys.find((k) => k.name === keyName);
|
|
|
|
if (!key || !key.npub) {
|
|
return this.rpc.sendResponse(req.id, req.pubkey, JSON.stringify([]), 24134);
|
|
}
|
|
|
|
const npub = key.npub;
|
|
|
|
const result = JSON.stringify(tokens.map((t) => {
|
|
return {
|
|
id: t.id,
|
|
key_name: t.keyName,
|
|
client_name: t.clientName,
|
|
token: [ npub, t.token ].join('#'),
|
|
policy_id: t.policyId,
|
|
policy_name: t.policy?.name,
|
|
created_at: t.createdAt,
|
|
updated_at: t.updatedAt,
|
|
expires_at: t.expiresAt,
|
|
redeemed_at: t.redeemedAt,
|
|
redeemed_by: t.KeyUser?.description,
|
|
time_until_expiration: t.expiresAt ? (t.expiresAt.getTime() - Date.now()) / 1000 : null,
|
|
};
|
|
}));
|
|
|
|
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
|
}
|
|
|
|
/**
|
|
* Command to list policies
|
|
*/
|
|
private async reqListPolicies(req: NDKRpcRequest) {
|
|
const policies = await prisma.policy.findMany({
|
|
include: {
|
|
rules: true,
|
|
},
|
|
});
|
|
|
|
const result = JSON.stringify(policies.map((p) => {
|
|
return {
|
|
id: p.id,
|
|
name: p.name,
|
|
description: p.description,
|
|
created_at: p.createdAt,
|
|
updated_at: p.updatedAt,
|
|
expires_at: p.expiresAt,
|
|
rules: p.rules.map((r) => {
|
|
return {
|
|
method: r.method,
|
|
kind: r.kind,
|
|
max_usage_count: r.maxUsageCount,
|
|
current_usage_count: r.currentUsageCount,
|
|
};
|
|
})
|
|
};
|
|
}));
|
|
|
|
return this.rpc.sendResponse(req.id, req.pubkey, result, 24134);
|
|
}
|
|
|
|
/**
|
|
* Command to fetch keys and their current state
|
|
*/
|
|
private async reqGetKeys(req: NDKRpcRequest) {
|
|
if (!this.getKeys) throw new Error('getKeys() not implemented');
|
|
|
|
const result = JSON.stringify(await this.getKeys());
|
|
const pubkey = req.pubkey;
|
|
|
|
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
|
|
}
|
|
|
|
/**
|
|
* Command to fetch users of a key
|
|
*/
|
|
private async reqGetKeyUsers(req: NDKRpcRequest): Promise<void> {
|
|
if (!this.getKeyUsers) throw new Error('getKeyUsers() not implemented');
|
|
|
|
const result = JSON.stringify(await this.getKeyUsers(req));
|
|
const pubkey = req.pubkey;
|
|
|
|
return this.rpc.sendResponse(req.id, pubkey, result, 24134); // 24134
|
|
}
|
|
|
|
/**
|
|
* This function is called when a request is received from a remote user that needs
|
|
* to be approved by the admin interface.
|
|
*
|
|
* @returns true if the request is approved, false if it is denied, undefined if it timedout
|
|
*/
|
|
public async requestPermission(
|
|
keyName: string,
|
|
remotePubkey: string,
|
|
method: string,
|
|
param: any
|
|
): Promise<boolean | undefined> {
|
|
const keyUser = await prisma.keyUser.findUnique({
|
|
where: {
|
|
unique_key_user: {
|
|
keyName,
|
|
userPubkey: remotePubkey,
|
|
},
|
|
},
|
|
});
|
|
|
|
console.trace({method, param});
|
|
|
|
if (method === 'sign_event') {
|
|
const e = param.rawEvent();
|
|
param = JSON.stringify(e);
|
|
|
|
console.log(`👀 Event to be signed\n`, {
|
|
kind: e.kind,
|
|
content: e.content,
|
|
tags: e.tags,
|
|
});
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
console.log(`requesting permission for`, keyName);
|
|
console.log(`remotePubkey`, remotePubkey);
|
|
console.log(`method`, method);
|
|
console.log(`param`, param);
|
|
console.log(`keyUser`, keyUser);
|
|
|
|
/**
|
|
* If an admin doesn't respond within 10 seconds, report back to the user that the request timed out
|
|
*/
|
|
setTimeout(() => {
|
|
resolve(undefined);
|
|
}, 10000);
|
|
|
|
for (const npub of this.npubs) {
|
|
const remoteUser = new NDKUser({npub});
|
|
console.log(`sending request to ${npub}`, remoteUser.pubkey);
|
|
const params = JSON.stringify({
|
|
keyName,
|
|
remotePubkey,
|
|
method,
|
|
param,
|
|
description: keyUser?.description,
|
|
});
|
|
|
|
this.rpc.sendRequest(
|
|
remoteUser.pubkey,
|
|
'acl',
|
|
[params],
|
|
24134,
|
|
(res: NDKRpcResponse) => {
|
|
this.requestPermissionResponse(
|
|
remotePubkey,
|
|
keyName,
|
|
method,
|
|
param,
|
|
resolve,
|
|
res
|
|
);
|
|
}
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
public async requestPermissionResponse(
|
|
remotePubkey: string,
|
|
keyName: string,
|
|
method: string,
|
|
param: string,
|
|
resolve: (value: boolean) => void,
|
|
res: NDKRpcResponse
|
|
) {
|
|
let resObj;
|
|
try {
|
|
resObj = JSON.parse(res.result);
|
|
} catch (e) {
|
|
console.log('error parsing result', e);
|
|
return;
|
|
}
|
|
|
|
switch (resObj[0]) {
|
|
case 'always': {
|
|
allowAllRequestsFromKey(
|
|
remotePubkey,
|
|
keyName,
|
|
method,
|
|
param,
|
|
resObj[1],
|
|
resObj[2]
|
|
);
|
|
resolve(true);
|
|
break;
|
|
}
|
|
case 'never': {
|
|
console.log('not implemented');
|
|
break;
|
|
}
|
|
default:
|
|
console.log('request result', res.result);
|
|
}
|
|
}
|
|
}
|
|
|
|
export default AdminInterface;
|