mirror of
https://github.com/kind-0/nsecbunkerd.git
synced 2025-03-17 13:22:54 +01:00
initial commit
This commit is contained in:
commit
54de9cfa8e
7
.env
Normal file
7
.env
Normal file
@ -0,0 +1,7 @@
|
||||
# Environment variables declared in this file are automatically made available to Prisma.
|
||||
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
|
||||
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||
|
||||
DATABASE_URL="file:./dev.db"
|
27
.eslintrc.json
Normal file
27
.eslintrc.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"prettier"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": 2021
|
||||
},
|
||||
"plugins": ["@typescript-eslint"],
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "args": "none" }], // No warnings for unused function arguments, which might be used in the future.
|
||||
"no-constant-binary-expression": "error",
|
||||
"semi": ["error", "always"]
|
||||
},
|
||||
"globals": {
|
||||
"Buffer": true,
|
||||
"expect": true,
|
||||
"process": true,
|
||||
"test": true
|
||||
}
|
||||
}
|
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
**/*.js
|
||||
dist
|
||||
**/*.d.ts
|
||||
**/*.d.ts.map
|
||||
nsecbunker.json
|
||||
# Environment variables declared in this file are automatically made available to Prisma.
|
||||
# See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema
|
||||
|
||||
# Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB.
|
||||
# See the documentation for all the connection string options: https://pris.ly/d/connection-strings
|
||||
|
||||
DATABASE_URL="file:./dev.db"
|
7
.prettierrc.json
Normal file
7
.prettierrc.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"importOrder": ["^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"semi": true
|
||||
}
|
22
README.md
Normal file
22
README.md
Normal file
@ -0,0 +1,22 @@
|
||||
# nsecbunker
|
||||
Daemon to remotely sign nostr events using keys.
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
npm i -g nsecbunker
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
nsecbunker -
|
||||
|
||||
# Authors
|
||||
|
||||
* [pablof7z](nostr:npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft)
|
||||
* npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft
|
||||
|
||||
# License
|
||||
|
||||
CC BY-NC-ND 3.0
|
||||
Contact @pablof7z for licensing.
|
5950
package-lock.json
generated
Normal file
5950
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "nsecbunker",
|
||||
"version": "0.1.0",
|
||||
"description": "nsecbunker",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"nsecbunkerd": "dist/index.js",
|
||||
"nsecbunker-client": "dist/client.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sanity-island/nsecbunker"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"nsecbunkerd": "node dist/index.js",
|
||||
"nsecbunker-client": "node dist/client.js"
|
||||
},
|
||||
"keywords": [
|
||||
"nostr"
|
||||
],
|
||||
"author": "pablof7z",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@inquirer/password": "^1.0.0",
|
||||
"@inquirer/prompts": "^1.0.0",
|
||||
"@prisma/client": "4.13.0",
|
||||
"@scure/base": "^1.1.1",
|
||||
"@types/yargs": "^17.0.24",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.0",
|
||||
"@typescript-eslint/parser": "^5.57.0",
|
||||
"crypto": "^1.0.1",
|
||||
"dotenv": "^16.0.3",
|
||||
"eslint": "^8.37.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-import": "^2.27.5",
|
||||
"eventemitter3": "^5.0.0",
|
||||
"websocket-polyfill": "^0.0.3",
|
||||
"yargs": "^17.7.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/node": "^18.15.11",
|
||||
"prisma": "^4.13.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.0.3"
|
||||
}
|
||||
}
|
15
prisma/migrations/20230503095726_init/migration.sql
Normal file
15
prisma/migrations/20230503095726_init/migration.sql
Normal file
@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "KeyUser" (
|
||||
"keyName" TEXT NOT NULL PRIMARY KEY,
|
||||
"userNpub" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SigningCondition" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"kind" INTEGER,
|
||||
"content" TEXT,
|
||||
"keyUserKeyName" TEXT,
|
||||
"allowed" BOOLEAN,
|
||||
CONSTRAINT "SigningCondition_keyUserKeyName_fkey" FOREIGN KEY ("keyUserKeyName") REFERENCES "KeyUser" ("keyName") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
8
prisma/migrations/20230503100402_unique/migration.sql
Normal file
8
prisma/migrations/20230503100402_unique/migration.sql
Normal file
@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[keyName,userNpub]` on the table `KeyUser` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "KeyUser_keyName_userNpub_key" ON "KeyUser"("keyName", "userNpub");
|
34
prisma/migrations/20230503100514_rename/migration.sql
Normal file
34
prisma/migrations/20230503100514_rename/migration.sql
Normal file
@ -0,0 +1,34 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `KeyUser` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `userNpub` on the `KeyUser` table. All the data in the column will be lost.
|
||||
- Added the required column `id` to the `KeyUser` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `userPubkey` to the `KeyUser` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- RedefineTables
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_KeyUser" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"keyName" TEXT NOT NULL,
|
||||
"userPubkey" TEXT NOT NULL
|
||||
);
|
||||
INSERT INTO "new_KeyUser" ("keyName") SELECT "keyName" FROM "KeyUser";
|
||||
DROP TABLE "KeyUser";
|
||||
ALTER TABLE "new_KeyUser" RENAME TO "KeyUser";
|
||||
CREATE UNIQUE INDEX "KeyUser_keyName_userPubkey_key" ON "KeyUser"("keyName", "userPubkey");
|
||||
CREATE TABLE "new_SigningCondition" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"kind" INTEGER,
|
||||
"content" TEXT,
|
||||
"keyUserKeyName" TEXT,
|
||||
"allowed" BOOLEAN,
|
||||
"keyUserId" INTEGER,
|
||||
CONSTRAINT "SigningCondition_keyUserId_fkey" FOREIGN KEY ("keyUserId") REFERENCES "KeyUser" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_SigningCondition" ("allowed", "content", "id", "keyUserKeyName", "kind") SELECT "allowed", "content", "id", "keyUserKeyName", "kind" FROM "SigningCondition";
|
||||
DROP TABLE "SigningCondition";
|
||||
ALTER TABLE "new_SigningCondition" RENAME TO "SigningCondition";
|
||||
PRAGMA foreign_key_check;
|
||||
PRAGMA foreign_keys=ON;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SigningCondition" ADD COLUMN "method" TEXT;
|
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "sqlite"
|
43
prisma/schema.prisma
Normal file
43
prisma/schema.prisma
Normal file
@ -0,0 +1,43 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model KeyUser {
|
||||
id Int @id @default(autoincrement())
|
||||
keyName String
|
||||
userPubkey String
|
||||
signingConditions SigningCondition[]
|
||||
logs Log[]
|
||||
|
||||
@@unique([keyName, userPubkey], name: "unique_key_user")
|
||||
}
|
||||
|
||||
model SigningCondition {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
method String?
|
||||
kind Int?
|
||||
content String?
|
||||
keyUserKeyName String?
|
||||
allowed Boolean?
|
||||
KeyUser KeyUser? @relation(fields: [keyUserId], references: [id])
|
||||
keyUserId Int?
|
||||
}
|
||||
|
||||
model Log {
|
||||
id Int @id @default(autoincrement())
|
||||
timestamp DateTime
|
||||
type String
|
||||
method String?
|
||||
params String?
|
||||
KeyUser KeyUser? @relation(fields: [keyUserId], references: [id])
|
||||
keyUserId Int?
|
||||
}
|
56
src/client.ts
Normal file
56
src/client.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import NDK, { NDKEvent, NDKPrivateKeySigner, NDKNip46Signer, NostrEvent } from '@nostr-dev-kit/ndk';
|
||||
|
||||
const remotePubkey = process.env.PUBKEY;
|
||||
|
||||
if (!remotePubkey) {
|
||||
console.log('Usage: PUBKEY=<pubkey> node src/client.js <content>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pubkey = process.argv[2];
|
||||
const content = process.argv[3];
|
||||
|
||||
if (!content) {
|
||||
console.log('Usage: node src/client.js <remote-pubkey> <content>');
|
||||
console.log('');
|
||||
console.log(`\t<remote-pubkey>: npub that should be published as`);
|
||||
console.log(`\t<content>: event JSON to sign | or kind:1 content string to sign`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function createNDK(): Promise<NDK> {
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: ['wss://nos.lol'],
|
||||
});
|
||||
await ndk.connect(2000);
|
||||
ndk.pool.on('connect', () => console.log('✅ connected'));
|
||||
|
||||
return ndk;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const ndk = await createNDK();
|
||||
const localSigner = new NDKPrivateKeySigner('9ec8a4b2e1fac9eae616736f718f92ed30c57fc2fff36ef8139e27c31889e327');
|
||||
const signer = new NDKNip46Signer(ndk, remotePubkey, localSigner);
|
||||
console.log(`local pubkey`, (await signer.user()).npub);
|
||||
console.log(`remote pubkey`, remotePubkey);
|
||||
ndk.signer = signer;
|
||||
|
||||
setTimeout(async () => {
|
||||
await signer.blockUntilReady();
|
||||
console.log(`authorized to sign as`, remotePubkey);
|
||||
|
||||
const notPabloEvent = new NDKEvent(ndk, {
|
||||
pubkey: remotePubkey,
|
||||
kind: 1,
|
||||
content,
|
||||
tags: [
|
||||
['t', 'grownostr'],
|
||||
],
|
||||
} as NostrEvent);
|
||||
|
||||
await notPabloEvent.sign();
|
||||
console.log('resulting event', JSON.stringify(await notPabloEvent.toNostrEvent()));
|
||||
// await notPabloEvent.publish();
|
||||
}, 2000);
|
||||
})();
|
49
src/commands/add.ts
Normal file
49
src/commands/add.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import {nip19, getPublicKey} from 'nostr-tools';
|
||||
import readline from 'readline';
|
||||
import { getCurrentConfig, saveCurrentConfig } from '../config/index.js';
|
||||
import { encryptNsec } from '../config/keys.js';
|
||||
|
||||
interface IOpts {
|
||||
config: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function saveEncrypted(config: string, nsec: string, passphrase: string, name: string) {
|
||||
const { iv, data } = encryptNsec(nsec, passphrase);
|
||||
const currentConfig = getCurrentConfig(config);
|
||||
|
||||
currentConfig.keys[name] = { iv, data };
|
||||
|
||||
saveCurrentConfig(config, currentConfig);
|
||||
}
|
||||
|
||||
export async function addNsec(opts: IOpts) {
|
||||
const name = opts.name;
|
||||
const config = opts.config;
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
console.log(`nsecBunker uses a passphrase to encrypt your nsec when stored on-disk.\n` +
|
||||
`Every time you restart it, you will need to type in this password.` +
|
||||
`\n`);
|
||||
|
||||
rl.question(`Enter a passphrase: `, (passphrase: string) => {
|
||||
rl.question(`Enter the nsec for ${name}: `, (nsec: string) => {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = nip19.decode(nsec);
|
||||
const hexpubkey = getPublicKey(decoded.data as string);
|
||||
const npub = nip19.npubEncode(hexpubkey);
|
||||
saveEncrypted(config, nsec, passphrase, name);
|
||||
|
||||
rl.close();
|
||||
} catch (e: any) {
|
||||
console.log(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
7
src/commands/setup.ts
Normal file
7
src/commands/setup.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import readline from 'readline';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function setup(config: string) {
|
||||
|
||||
}
|
84
src/commands/start.ts
Normal file
84
src/commands/start.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import readline from 'readline';
|
||||
import { getCurrentConfig } from '../config/index.js';
|
||||
import { decryptNsec } from '../config/keys.js';
|
||||
import { fork } from 'child_process';
|
||||
import { resolve } from 'path';
|
||||
|
||||
interface IOpts {
|
||||
keys: string[];
|
||||
verbose: boolean;
|
||||
config: string;
|
||||
}
|
||||
|
||||
export async function start(opts: IOpts) {
|
||||
const configData = getCurrentConfig(opts.config);
|
||||
|
||||
if (opts.verbose) {
|
||||
configData.verbose = opts.verbose;
|
||||
}
|
||||
|
||||
const keys: Record<string, string> = {};
|
||||
|
||||
let keysToStart = opts.keys;
|
||||
|
||||
if (!keysToStart) {
|
||||
keysToStart = Object.keys(configData.keys);
|
||||
}
|
||||
|
||||
for (const keyName of keysToStart) {
|
||||
const nsec = await startKey(keyName, configData.keys[keyName], opts.verbose);
|
||||
|
||||
if (nsec) {
|
||||
keys[keyName] = nsec;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(keys).length === 0) {
|
||||
console.log(`No keys started.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`nsecBunker starting with keys:`, Object.keys(keys).join(', '));
|
||||
|
||||
configData.keys = keys;
|
||||
|
||||
const daemonProcess = fork(resolve(__dirname, '../daemon/index.js'));
|
||||
daemonProcess.send(configData);
|
||||
|
||||
// process.exit(0);
|
||||
}
|
||||
|
||||
interface KeyData {
|
||||
iv: string;
|
||||
data: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a key
|
||||
*/
|
||||
async function startKey(key: string, keyData: KeyData, verbose: boolean): Promise<string | undefined> {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`Enter passphrase for ${key}: `, (passphrase: string) => {
|
||||
try {
|
||||
const { iv, data } = keyData;
|
||||
const nsec = decryptNsec(iv, data, passphrase);
|
||||
|
||||
if (verbose) {
|
||||
console.log(`Starting ${key}...`);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
|
||||
resolve(nsec);
|
||||
} catch (e: any) {
|
||||
console.log(e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
53
src/config/index.ts
Normal file
53
src/config/index.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
function getPassphrase(): string {
|
||||
const passwordLength = 32;
|
||||
const passwordBytes = randomBytes(passwordLength);
|
||||
return passwordBytes.toString('base64').slice(0, passwordLength);
|
||||
}
|
||||
|
||||
const defaultConfig = {
|
||||
nostr: {
|
||||
relays: [
|
||||
'wss://nos.lol',
|
||||
// 'wss://relay.damus.io'
|
||||
]
|
||||
},
|
||||
remote: {
|
||||
passphrase: getPassphrase(),
|
||||
},
|
||||
database: 'sqlite://nsecbunker.db',
|
||||
logs: './nsecbunker.log',
|
||||
keys: {},
|
||||
verbose: false,
|
||||
};
|
||||
|
||||
export function getCurrentConfig(config: string) {
|
||||
try {
|
||||
const configFileContents = readFileSync(config, 'utf8');
|
||||
return JSON.parse(configFileContents);
|
||||
} catch (err: any) {
|
||||
if (err.code === 'ENOENT') {
|
||||
const d = defaultConfig;
|
||||
|
||||
console.log(`nsecBunker generated an admin password for you:\n\n${d.remote.passphrase}\n\n` +
|
||||
`You will need this to manage users of your keys.\n\n`);
|
||||
|
||||
return defaultConfig;
|
||||
} else {
|
||||
console.error(`Error reading config file: ${err.message}`);
|
||||
process.exit(1); // Kill the process if there is an error parsing the JSON
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCurrentConfig(config: string, currentConfig: any) {
|
||||
try {
|
||||
const configString = JSON.stringify(currentConfig, null, 2);
|
||||
writeFileSync(config, configString);
|
||||
} catch (err: any) {
|
||||
console.error(`Error writing config file: ${err.message}`);
|
||||
process.exit(1); // Kill the process if there is an error parsing the JSON
|
||||
}
|
||||
}
|
26
src/config/keys.ts
Normal file
26
src/config/keys.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
export function encryptNsec(nsec: string, passphrase: string): { iv: string, data: string } {
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const key = crypto.createHash('sha256').update(passphrase).digest();
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||||
let encrypted = cipher.update(nsec);
|
||||
encrypted = Buffer.concat([encrypted, cipher.final()]);
|
||||
|
||||
return {
|
||||
iv: iv.toString('hex'),
|
||||
data: encrypted.toString('hex'),
|
||||
};
|
||||
}
|
||||
|
||||
export function decryptNsec(iv: string, data: string, passphrase: string): string {
|
||||
const algorithm = 'aes-256-cbc';
|
||||
const key = crypto.createHash('sha256').update(passphrase).digest();
|
||||
const ivBuffer = Buffer.from(iv, 'hex');
|
||||
const dataBuffer = Buffer.from(data, 'hex');
|
||||
const decipher = crypto.createDecipheriv(algorithm, key, ivBuffer);
|
||||
let decrypted = decipher.update(dataBuffer);
|
||||
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||
return decrypted.toString();
|
||||
}
|
9
src/daemon/backend/index.ts
Normal file
9
src/daemon/backend/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import NDK, { NDKNip46Backend, Nip46PermitCallback } from '@nostr-dev-kit/ndk';
|
||||
import PublishEventHandlingStrategy from './publish-event.js';
|
||||
|
||||
export class Backend extends NDKNip46Backend {
|
||||
constructor(ndk: NDK, key: string, cb: Nip46PermitCallback) {
|
||||
super(ndk, key, cb);
|
||||
this.setStrategy('publish_event', new PublishEventHandlingStrategy());
|
||||
}
|
||||
}
|
13
src/daemon/backend/publish-event.ts
Normal file
13
src/daemon/backend/publish-event.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { NDKNip46Backend } from "@nostr-dev-kit/ndk";
|
||||
import { IEventHandlingStrategy } from '@nostr-dev-kit/ndk/lib/src/signers/nip46/backend';
|
||||
|
||||
export default class PublishEventHandlingStrategy implements IEventHandlingStrategy {
|
||||
async handle(backend: NDKNip46Backend, remotePubkey: string, params: string[]): Promise<string|undefined> {
|
||||
const event = await backend.signEvent(remotePubkey, params);
|
||||
if (!event) return undefined;
|
||||
|
||||
backend.ndk.publish(event);
|
||||
|
||||
return JSON.stringify(await event.toNostrEvent());
|
||||
}
|
||||
}
|
6
src/daemon/index.ts
Normal file
6
src/daemon/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import run from './run';
|
||||
import type {IOpts} from './run';
|
||||
|
||||
process.on('message', (configData: IOpts) => {
|
||||
run(configData);
|
||||
});
|
272
src/daemon/run.ts
Normal file
272
src/daemon/run.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import NDK, { Nip46PermitCallback } from '@nostr-dev-kit/ndk';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { Backend } from './backend/index.js';
|
||||
import readline from 'readline';
|
||||
import prisma from '../db.js';
|
||||
|
||||
export interface IOpts {
|
||||
keys: Record<string, string>;
|
||||
nostr: {
|
||||
relays: string[],
|
||||
}
|
||||
verbose: boolean;
|
||||
}
|
||||
|
||||
|
||||
export default async function run(opts: IOpts) {
|
||||
console.log(`nsecBunker daemon starting with PID ${process.pid}...`);
|
||||
console.log(`Connecting to ${opts.nostr.relays.length} relays...`);
|
||||
|
||||
const ndk = new NDK({
|
||||
explicitRelayUrls: opts.nostr.relays,
|
||||
});
|
||||
await ndk.pool.on('connect', (r) => { console.log(`✅ Connected to ${r.url}`); });
|
||||
await ndk.pool.on('notice', (n, r) => { console.log(`👀 Notice from ${r.url}`, n); });
|
||||
await ndk.connect(5000);
|
||||
|
||||
setTimeout(async () => {
|
||||
const promise = [];
|
||||
|
||||
for (const [name, nsec] of Object.entries(opts.keys)) {
|
||||
const cb = callbackForKey(name);
|
||||
const hexpk = nip19.decode(nsec).data as string;
|
||||
const backend = new Backend(ndk, hexpk, cb);
|
||||
promise.push(backend.start());
|
||||
}
|
||||
|
||||
await Promise.all(promise);
|
||||
|
||||
console.log('✅ nsecBunker ready to serve requests.');
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function checkIfPubkeyAllowed(keyName: string, remotePubkey: string, method: string, param?: any): Promise<boolean | undefined> {
|
||||
// find KeyUser
|
||||
const keyUser = await prisma.keyUser.findUnique({
|
||||
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
|
||||
});
|
||||
|
||||
if (!keyUser) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// find SigningCondition
|
||||
const signingConditionQuery = requestToSigningConditionQuery(method, param);
|
||||
|
||||
const explicitReject = await prisma.signingCondition.findFirst({
|
||||
where: {
|
||||
keyUserId: keyUser.id,
|
||||
method: '*',
|
||||
allowed: false,
|
||||
}
|
||||
});
|
||||
|
||||
if (explicitReject) {
|
||||
console.log(`explicit reject`, explicitReject);
|
||||
return false;
|
||||
}
|
||||
|
||||
const signingCondition = await prisma.signingCondition.findFirst({
|
||||
where: {
|
||||
keyUserId: keyUser.id,
|
||||
...signingConditionQuery,
|
||||
}
|
||||
});
|
||||
|
||||
// if no SigningCondition found, return undefined
|
||||
if (!signingCondition) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const allowed = signingCondition.allowed;
|
||||
|
||||
if (allowed === true || allowed === false) {
|
||||
console.log(`found signing condition`, signingCondition);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function requestToSigningConditionQuery(method: string, param?: any) {
|
||||
const signingConditionQuery: any = { method };
|
||||
|
||||
switch (method) {
|
||||
case 'sign_event':
|
||||
signingConditionQuery.kind = param.kind;
|
||||
break;
|
||||
}
|
||||
|
||||
return signingConditionQuery;
|
||||
}
|
||||
|
||||
async function allowAllRequestsFromKey(remotePubkey: string, keyName: string, method: string, param?: any): Promise<void> {
|
||||
try {
|
||||
|
||||
// Upsert the KeyUser with the given remotePubkey
|
||||
const upsertedUser = await prisma.keyUser.upsert({
|
||||
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
|
||||
update: { },
|
||||
create: { keyName, userPubkey: remotePubkey },
|
||||
});
|
||||
|
||||
console.log({ upsertedUser });
|
||||
|
||||
// Create a new SigningCondition for the given KeyUser and set allowed to true
|
||||
const signingConditionQuery = requestToSigningConditionQuery(method, param);
|
||||
await prisma.signingCondition.create({
|
||||
data: {
|
||||
allowed: true,
|
||||
keyUserId: upsertedUser.id,
|
||||
...signingConditionQuery
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('allowAllRequestsFromKey', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectAllRequestsFromKey(remotePubkey: string, keyName: string): Promise<void> {
|
||||
// Upsert the KeyUser with the given remotePubkey
|
||||
const upsertedUser = await prisma.keyUser.upsert({
|
||||
where: { unique_key_user: { keyName, userPubkey: remotePubkey } },
|
||||
update: { },
|
||||
create: { keyName, userPubkey: remotePubkey },
|
||||
});
|
||||
|
||||
console.log({ upsertedUser });
|
||||
|
||||
// Create a new SigningCondition for the given KeyUser and set allowed to false
|
||||
await prisma.signingCondition.create({
|
||||
data: {
|
||||
allowed: false,
|
||||
keyUserId: upsertedUser.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
interface IAskYNquestionOpts {
|
||||
timeoutLength?: number;
|
||||
yes: any;
|
||||
no: any;
|
||||
always?: any;
|
||||
never?: any;
|
||||
response?: any;
|
||||
timeout?: any;
|
||||
}
|
||||
|
||||
async function askYNquestion(
|
||||
question: string,
|
||||
opts: IAskYNquestionOpts
|
||||
) {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
|
||||
if (opts.timeoutLength) {
|
||||
timeout = setTimeout(() => {
|
||||
rl.close();
|
||||
opts.timeout && opts.timeout();
|
||||
}, opts.timeoutLength);
|
||||
}
|
||||
|
||||
const prompts = ['y', 'n'];
|
||||
|
||||
if (opts.always) prompts.push('always');
|
||||
if (opts.never) prompts.push('never');
|
||||
|
||||
question += ` (${prompts.join('/')})`;
|
||||
|
||||
rl.question(question, (answer) => {
|
||||
timeout && clearTimeout(timeout);
|
||||
|
||||
switch (answer) {
|
||||
case 'y':
|
||||
case 'Y':
|
||||
opts.yes();
|
||||
opts.response && opts.response(answer);
|
||||
break;
|
||||
case 'n':
|
||||
case 'N':
|
||||
opts.no();
|
||||
opts.response && opts.response(answer);
|
||||
break;
|
||||
case 'always':
|
||||
opts.yes();
|
||||
opts.always();
|
||||
opts.response && opts.response(answer);
|
||||
break;
|
||||
case 'never':
|
||||
opts.no();
|
||||
opts.never();
|
||||
opts.response && opts.response(answer);
|
||||
break;
|
||||
default:
|
||||
console.log('Invalid answer');
|
||||
askYNquestion(question, opts);
|
||||
break;
|
||||
}
|
||||
|
||||
rl.close();
|
||||
});
|
||||
|
||||
return rl;
|
||||
}
|
||||
|
||||
async function requestPermission(keyName: string, remotePubkey: string, method: string, param?: any): Promise<boolean> {
|
||||
const npub = nip19.npubEncode(remotePubkey);
|
||||
|
||||
const promise = new Promise<boolean>((resolve, reject) => {
|
||||
const question = `👉 Do you want to allow ${npub} to ${method} with key ${keyName}?`;
|
||||
|
||||
if (method === 'sign_event') {
|
||||
const e = param.rawEvent();
|
||||
|
||||
console.log(`👀 Event to be signed\n`, {
|
||||
kind: e.kind,
|
||||
content: e.content,
|
||||
tags: e.tags,
|
||||
});
|
||||
}
|
||||
|
||||
askYNquestion(question, {
|
||||
timeoutLength: 30000,
|
||||
yes: () => { resolve(true); },
|
||||
no: () => { resolve(false); },
|
||||
timeout: () => { console.log('🚫 Timeout reached, denying request.'); resolve(false); },
|
||||
always: async () => {
|
||||
console.log('✅ Allowing this request and all future requests from this key.');
|
||||
await allowAllRequestsFromKey(remotePubkey, keyName, method, param);
|
||||
},
|
||||
never: async () => {
|
||||
console.log('🚫 Denying this request and all future requests from this key.');
|
||||
await rejectAllRequestsFromKey(remotePubkey, keyName);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
function callbackForKey(keyName: string): Nip46PermitCallback {
|
||||
return async (remotePubkey: string, method: string, param?: any): Promise<boolean> => {
|
||||
try {
|
||||
const keyAllowed = await checkIfPubkeyAllowed(keyName, remotePubkey, method, param);
|
||||
|
||||
if (keyAllowed === true || keyAllowed === false) {
|
||||
console.log(`🔎 ${nip19.npubEncode(remotePubkey)} is ${keyAllowed ? 'allowed' : 'denied'} to ${method} with key ${keyName}`);
|
||||
return keyAllowed;
|
||||
}
|
||||
|
||||
// No explicit allow or deny, ask the user
|
||||
return requestPermission(keyName, remotePubkey, method, param);
|
||||
} catch(e) {
|
||||
console.log('callbackForKey error:', e);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
5
src/db.ts
Normal file
5
src/db.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export default prisma;
|
113
src/index.ts
Normal file
113
src/index.ts
Normal file
@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env node
|
||||
import 'websocket-polyfill';
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
|
||||
import { setup } from './commands/setup.js';
|
||||
import { addNsec } from './commands/add.js';
|
||||
import { start } from './commands/start.js';
|
||||
|
||||
console.log(`nsecBunker licensed under CC BY-NC-ND 3.0:`);
|
||||
console.log(`free to use for non-commercial use`);
|
||||
console.log(`Copyright by pablof7z <npub1l2vyh47mk2p0qlsku7hg0vn29faehy9hy34ygaclpn66ukqp3afqutajft> 2023`);
|
||||
console.log(`Contact for licensing`);
|
||||
console.log(``);
|
||||
|
||||
yargs(hideBin(process.argv))
|
||||
.command('setup', 'Setup nsecBunker', () => {}, (argv) => {
|
||||
setup(argv.config as string);
|
||||
})
|
||||
|
||||
.command('start', 'Start nsecBunker', (yargs) => {
|
||||
yargs
|
||||
.option('verbose', {
|
||||
alias: 'v',
|
||||
type: 'boolean',
|
||||
description: 'Run with verbose logging',
|
||||
default: false,
|
||||
})
|
||||
.array('key')
|
||||
.option('key <name>', {
|
||||
type: 'string',
|
||||
description: 'Name of key to enable',
|
||||
});
|
||||
}, (argv) => {
|
||||
start({
|
||||
keys: argv.key as string[],
|
||||
verbose: argv.verbose as boolean,
|
||||
config: argv.config as string,
|
||||
|
||||
});
|
||||
})
|
||||
|
||||
.command('add', 'Add an nsec', (yargs) => {
|
||||
yargs
|
||||
.option('name', {
|
||||
alias: 'n',
|
||||
type: 'string',
|
||||
description: 'Name of the nsec',
|
||||
demandOption: true,
|
||||
});
|
||||
}, (argv) => {
|
||||
addNsec({
|
||||
config: argv.config as string,
|
||||
name: argv.name as string
|
||||
});
|
||||
})
|
||||
|
||||
.options({
|
||||
'config': {
|
||||
alias: 'c',
|
||||
type: 'string',
|
||||
description: 'Path to config file',
|
||||
default: 'nsecbunker.json',
|
||||
},
|
||||
})
|
||||
.demandCommand(1)
|
||||
.parse();
|
||||
|
||||
|
||||
|
||||
// async function cb(pubkey: string, method: string, param?: any): Promise<boolean> {
|
||||
// // check if pubkey is in allowed list file
|
||||
// // if not, return false
|
||||
// // if yes, return true
|
||||
|
||||
// // read file allowed.json
|
||||
// try {
|
||||
// const data = fs.readFileSync('config.json', 'utf8');
|
||||
// const config = JSON.parse(data);
|
||||
// const allowedPubkeys = config.allowedPubkeys || {};
|
||||
// console.log('allowedPubkeys', allowedPubkeys, allowedPubkeys[pubkey]);
|
||||
// if (allowedPubkeys[pubkey] && allowedPubkeys[pubkey].methods[method]) {
|
||||
// console.log(`✅ ${pubkey} is allowed to ${method}`);
|
||||
// return true;
|
||||
// }
|
||||
// } catch(e) {
|
||||
// console.log('Error:', e);
|
||||
// }
|
||||
|
||||
// console.log(`🚫 ${pubkey} is not allowed to ${method}`);
|
||||
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// (async () => {
|
||||
// const ndk = await createNDK();
|
||||
|
||||
// console.log(`NSECBUNKER BOOTING UP`);
|
||||
|
||||
// if (!process.env.PKEY) {
|
||||
// console.error('PKEY not set');
|
||||
// process.exit(1);
|
||||
// }
|
||||
|
||||
// const backend = new Backend(ndk, process.env.PKEY, cb);
|
||||
// await backend.start();
|
||||
|
||||
// const npub = backend.localUser?.npub;
|
||||
// const hexpubkey = backend.localUser?.hexpubkey();
|
||||
// console.log(`NPUB: ${npub}`);
|
||||
// console.log(`PUBK: ${hexpubkey}`);
|
||||
// })();
|
||||
|
74
tsconfig.json
Normal file
74
tsconfig.json
Normal file
@ -0,0 +1,74 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
|
||||
/* Basic Options */
|
||||
// "incremental": true, /* Enable incremental compilation */
|
||||
"target": "es2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */,
|
||||
"module": "CommonJS", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
"declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
"sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "dist", /* Redirect output structure to the directory. */
|
||||
"rootDir": "src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
// "resolveJsonModule": true,
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
|
||||
/* Advanced Options */
|
||||
"skipLibCheck": true, /* Skip type checking of declaration files. */
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.spec.ts"
|
||||
]
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user