nappup 2.0.0 → 2.1.0

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/README.md CHANGED
@@ -33,6 +33,7 @@ nappup [directory] [options]
33
33
  | `--main` | Publish to the **main** release channel. This is the default behavior. |
34
34
  | `--next` | Publish to the **next** release channel. Ideal for beta testing or staging builds. |
35
35
  | `--draft` | Publish to the **draft** release channel. Use this for internal testing or work-in-progress builds. |
36
+ | `--dotenv-private-key <hex>` | Use a 32-byte hex private key to decrypt and manage encrypted credentials in `.env`. Prefer `DOTENV_PRIVATE_KEY_NAPPUP` because command-line arguments can be visible in shell history and process listings. |
36
37
 
37
38
  ## Authentication
38
39
 
@@ -47,6 +48,7 @@ Napp Up! supports multiple ways to provide your Nostr secret key:
47
48
  ```bash
48
49
  nappup -s 'bunker://<pubkey>?relay=wss://relay.example.com&secret=<token>'
49
50
  ```
51
+ Napp Up! creates a persistent NIP-46 client key before connecting. For URLs passed with `-s`, the latest session is stored as `LAST_CLI_BUNKER_SESSION` in the project's `.env`. A URL without `secret` is also valid when the remote signer uses its public key as the access capability.
50
52
 
51
53
  3. **Environment variable**: Set `NOSTR_SECRET_KEY` in your environment or a `.env` file (also supports `bunker://` URLs):
52
54
  ```bash
@@ -56,6 +58,37 @@ Napp Up! supports multiple ways to provide your Nostr secret key:
56
58
 
57
59
  4. **Auto-generated key**: If no key is provided, Napp Up! will generate a new keypair automatically and store it (as nsec) in your project's `.env` file for future use.
58
60
 
61
+ When `NOSTR_SECRET_KEY` in `.env` is a bunker URL, Napp Up! adds the local client key as a `#client_key=...` URI fragment while retaining the query-string `secret` for compatibility fallback. Reconnects first use the same client key without the token and retry with the token only if necessary.
62
+
63
+ Napp Up! stores `NOSTR_SECRET_KEY` and `LAST_CLI_BUNKER_SESSION` in the official dotenvx `encrypted:<base64>` format, using the compressed public key in `DOTENV_PUBLIC_KEY_NAPPUP`. The private key is selected from `--dotenv-private-key`, then `DOTENV_PRIVATE_KEY_NAPPUP` in the process environment, and finally a built-in fallback. The public key is selected from the `.env`, then the process environment, and finally derived from the selected private key.
64
+
65
+ The built-in fallback makes migration automatic, but is public knowledge and therefore only hides plaintext; use an explicit private key for confidentiality. Generate one locally for storage in your secret manager with:
66
+
67
+ ```bash
68
+ nappup env keygen
69
+ ```
70
+
71
+ Alternatively, generate and export one directly into the current shell:
72
+
73
+ ```bash
74
+ export DOTENV_PRIVATE_KEY_NAPPUP="$(nappup env keygen)"
75
+ ```
76
+
77
+ `env keygen` writes only the generated 64-character lowercase hex key to standard output, writes a reminder to standard error, and never reads or modifies `.env`. Store the result in a secret manager; nappup cannot recover it.
78
+
79
+ Because encryption itself requires only the public key, the following command can safely replace the Nostr secret without receiving the private key:
80
+
81
+ ```bash
82
+ nappup env set NOSTR_SECRET_KEY
83
+ printf '%s\n' 'nsec1...' | nappup env set NOSTR_SECRET_KEY
84
+ ```
85
+
86
+ The interactive form hides and confirms the value. Redirected stdin and a positional value are also accepted; the positional form emits a warning because it may be exposed through shell history or process listings. With only `DOTENV_PUBLIC_KEY_NAPPUP`, `env set` can encrypt a replacement, while commands that need an existing value fail clearly without the matching private key.
87
+
88
+ Existing plaintext values are encrypted automatically when used. If an explicit private key does not match the stored public key, it becomes authoritative: recoverable values are re-encrypted and inaccessible credentials are reset with a warning naming only the affected variables. This can result in a new publisher identity or bunker client key.
89
+
90
+ Use `DOTENV_CONFIG_PATH` to select a different dotenv file. The private key must come from the process environment or CLI and is rejected if stored inside that file.
91
+
59
92
  ### Examples
60
93
 
61
94
  Upload the current directory to the main channel:
@@ -6,12 +6,15 @@ import mime from 'mime-types'
6
6
  import { fileTypeFromFile } from 'file-type'
7
7
 
8
8
  export function parseArgs (args) {
9
+ if (args[0] === 'env') return parseEnvArgs(args)
10
+
9
11
  let dir = null
10
12
  let sk = null
11
13
  let dTag = null
12
14
  let channel = null
13
15
  let shouldReupload = false
14
16
  let yes = false
17
+ let dotenvPrivateKey = null
15
18
 
16
19
  for (let i = 0; i < args.length; i++) {
17
20
  if (args[i] === '-s' && args[i + 1]) {
@@ -30,19 +33,123 @@ export function parseArgs (args) {
30
33
  shouldReupload = true
31
34
  } else if (args[i] === '-y') {
32
35
  yes = true
36
+ } else if (args[i] === '--dotenv-private-key' && args[i + 1]) {
37
+ dotenvPrivateKey = args[i + 1]
38
+ i++
39
+ } else if (args[i] === '--dotenv-private-key') {
40
+ throw new Error('--dotenv-private-key requires a value')
33
41
  } else if (!args[i].startsWith('-') && dir === null) {
34
42
  dir = args[i]
35
43
  }
36
44
  }
37
45
 
38
46
  return {
47
+ command: 'upload',
39
48
  dir: path.resolve(dir ?? '.'),
40
49
  sk,
41
50
  dTag,
42
51
  channel: channel || 'main',
43
52
  shouldReupload,
44
- yes
53
+ yes,
54
+ dotenvPrivateKey
55
+ }
56
+ }
57
+
58
+ function parseEnvArgs (args) {
59
+ if (args[1] === 'keygen') {
60
+ if (args.length !== 2) throw new Error('Expected: nappup env keygen')
61
+ return { command: 'env-keygen' }
62
+ }
63
+ if (args[1] !== 'set') throw new Error('Expected: nappup env set NOSTR_SECRET_KEY [value]')
64
+ if (args[2] !== 'NOSTR_SECRET_KEY') throw new Error('Only NOSTR_SECRET_KEY can be set')
65
+
66
+ let value = null
67
+ let dotenvPrivateKey = null
68
+ for (let i = 3; i < args.length; i++) {
69
+ if (args[i] === '--dotenv-private-key') {
70
+ if (!args[i + 1]) throw new Error('--dotenv-private-key requires a value')
71
+ dotenvPrivateKey = args[++i]
72
+ } else if (args[i] === '--') {
73
+ if (value !== null || !args[i + 1] || i + 2 !== args.length) throw new Error('Expected one NOSTR_SECRET_KEY value')
74
+ value = args[++i]
75
+ } else if (value === null) {
76
+ value = args[i]
77
+ } else {
78
+ throw new Error('Expected one NOSTR_SECRET_KEY value')
79
+ }
45
80
  }
81
+
82
+ return {
83
+ command: 'env-set',
84
+ name: 'NOSTR_SECRET_KEY',
85
+ value,
86
+ dotenvPrivateKey
87
+ }
88
+ }
89
+
90
+ function hiddenQuestion (query, { input, output }) {
91
+ return new Promise((resolve, reject) => {
92
+ const wasRaw = Boolean(input.isRaw)
93
+ const wasPaused = input.isPaused?.() ?? false
94
+ let value = ''
95
+
96
+ function cleanup () {
97
+ input.off('keypress', onKeypress)
98
+ input.setRawMode(wasRaw)
99
+ if (wasPaused) input.pause()
100
+ }
101
+
102
+ function onKeypress (text, key = {}) {
103
+ if (key.ctrl && key.name === 'c') {
104
+ cleanup()
105
+ output.write('\n')
106
+ const error = new Error('Operation cancelled by user')
107
+ error.code = 'ABORT_ERR'
108
+ reject(error)
109
+ } else if (key.name === 'return' || key.name === 'enter') {
110
+ cleanup()
111
+ output.write('\n')
112
+ resolve(value)
113
+ } else if (key.name === 'backspace') {
114
+ value = value.slice(0, -1)
115
+ } else if (text && !key.ctrl && !key.meta) {
116
+ value += text
117
+ }
118
+ }
119
+
120
+ readline.emitKeypressEvents(input)
121
+ input.setRawMode(true)
122
+ input.resume()
123
+ input.on('keypress', onKeypress)
124
+ output.write(query)
125
+ })
126
+ }
127
+
128
+ async function readPipedValue (input) {
129
+ let value = ''
130
+ for await (const chunk of input) {
131
+ value += chunk
132
+ if (value.length > 65536) throw new Error('NOSTR_SECRET_KEY input is too large')
133
+ }
134
+ return value.replace(/(?:\r\n|\n)$/, '')
135
+ }
136
+
137
+ export async function readEnvSetValue (args, {
138
+ input = process.stdin,
139
+ output = process.stdout,
140
+ errorOutput = process.stderr
141
+ } = {}) {
142
+ if (args.value !== null) {
143
+ errorOutput.write('Warning: a plaintext value passed as an argument may be visible in shell history and process listings.\n')
144
+ return args.value
145
+ }
146
+ if (!input.isTTY) return readPipedValue(input)
147
+ if (typeof input.setRawMode !== 'function') throw new Error('Cannot hide input on this terminal')
148
+
149
+ const first = await hiddenQuestion('NOSTR_SECRET_KEY: ', { input, output })
150
+ const second = await hiddenQuestion('Confirm NOSTR_SECRET_KEY: ', { input, output })
151
+ if (first !== second) throw new Error('NOSTR_SECRET_KEY values do not match')
152
+ return first
46
153
  }
47
154
 
48
155
  export async function confirmArgs (args) {
@@ -1,51 +1,85 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'node:path'
3
- import NostrSigner from '#services/nostr-signer.js'
4
3
  import { GENERIC_BUILD_FOLDER_NAMES } from '#helpers/app.js'
5
4
  import {
6
5
  parseArgs,
7
6
  confirmArgs,
7
+ readEnvSetValue,
8
8
  toFileList,
9
9
  getFiles
10
10
  } from './helpers.js'
11
- import toApp from '#index.js'
12
11
 
13
12
  const args = parseArgs(process.argv.slice(2))
14
13
 
15
- let { dTag } = args
16
- const { dir, sk, channel, shouldReupload } = args
17
-
18
- if (!dTag) {
19
- let folderName = path.basename(dir)
20
- if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
21
- const parentName = path.basename(path.dirname(dir))
22
- if (parentName && parentName !== '.' && parentName !== '/' && !GENERIC_BUILD_FOLDER_NAMES.has(parentName.toLowerCase())) {
23
- folderName = parentName
24
- } else {
25
- console.error(`Directory name "${folderName}" is a generic build folder. Please provide a d tag with -d.`)
26
- process.exit(1)
27
- }
14
+ if (args.command === 'env-keygen') {
15
+ const { keypair } = await import('@dotenvx/primitives')
16
+ const { privateKey } = keypair()
17
+ console.error('Keep this dotenv private key secret; nappup has not stored it.')
18
+ process.stdout.write(`${privateKey}\n`)
19
+ } else {
20
+ const dotenv = await import('#services/dotenv.js')
21
+ const dotenvState = dotenv.initializeDotenv({
22
+ privateKey: args.dotenvPrivateKey,
23
+ loadNostrSecretKey: args.command === 'upload' && !args.sk,
24
+ reconcilePrivateKey: args.command === 'upload'
25
+ })
26
+
27
+ if (args.command === 'env-set') {
28
+ const { validateNostrSecretKey } = await import('#services/nostr-secret-key.js')
29
+ const value = await readEnvSetValue(args)
30
+ validateNostrSecretKey(value)
31
+ dotenv.setEncryptedDotenvValue(args.name, value, {
32
+ filePath: dotenvState.filePath,
33
+ updateProcessEnv: false
34
+ })
35
+ console.log(`Set encrypted ${args.name} in ${dotenvState.filePath}`)
36
+ } else {
37
+ await upload(args, dotenvState)
28
38
  }
29
- dTag = folderName
30
39
  }
31
- args.dTag = dTag
32
40
 
33
- await confirmArgs(args)
41
+ async function upload (args, dotenvState) {
42
+ let { dTag } = args
43
+ const { dir, sk, channel, shouldReupload } = args
34
44
 
35
- const fileList = await toFileList(getFiles(dir), dir)
45
+ if (!dTag) {
46
+ let folderName = path.basename(dir)
47
+ if (GENERIC_BUILD_FOLDER_NAMES.has(folderName.toLowerCase())) {
48
+ const parentName = path.basename(path.dirname(dir))
49
+ if (parentName && parentName !== '.' && parentName !== '/' && !GENERIC_BUILD_FOLDER_NAMES.has(parentName.toLowerCase())) {
50
+ folderName = parentName
51
+ } else {
52
+ console.error(`Directory name "${folderName}" is a generic build folder. Please provide a d tag with -d.`)
53
+ process.exit(1)
54
+ }
55
+ }
56
+ dTag = folderName
57
+ }
58
+ args.dTag = dTag
36
59
 
37
- const bunkerUrl = sk?.startsWith('bunker://') ? sk : (!sk && process.env.NOSTR_SECRET_KEY?.startsWith('bunker://')) ? process.env.NOSTR_SECRET_KEY : null
60
+ await confirmArgs(args)
61
+ const fileList = await toFileList(getFiles(dir), dir)
62
+ const bunkerUrl = sk?.startsWith('bunker://')
63
+ ? sk
64
+ : !sk && process.env.NOSTR_SECRET_KEY?.startsWith('bunker://')
65
+ ? process.env.NOSTR_SECRET_KEY
66
+ : null
38
67
 
39
- let signer
40
- if (bunkerUrl) {
41
- const { default: NostrBunkerSigner } = await import('#services/bunker-signer.js')
42
- signer = await NostrBunkerSigner.create(bunkerUrl)
43
- } else {
44
- signer = await NostrSigner.create(sk)
45
- }
68
+ let signer
69
+ if (bunkerUrl) {
70
+ const { default: NostrBunkerSigner } = await import('#services/bunker-signer.js')
71
+ signer = await NostrBunkerSigner.create(bunkerUrl, {
72
+ source: sk ? 'cli' : dotenvState.nostrSecretKeySource === 'dotenv' ? 'dotenv' : 'cli'
73
+ })
74
+ } else {
75
+ const { default: NostrSigner } = await import('#services/nostr-signer.js')
76
+ signer = await NostrSigner.create(sk)
77
+ }
46
78
 
47
- try {
48
- await toApp(fileList, signer, { log: console.log.bind(console), dTag, channel, shouldReupload })
49
- } finally {
50
- await signer.close?.()
79
+ try {
80
+ const { default: toApp } = await import('#index.js')
81
+ await toApp(fileList, signer, { log: console.log.bind(console), dTag, channel, shouldReupload })
82
+ } finally {
83
+ await signer.close?.()
84
+ }
51
85
  }
package/package.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "url": "git+https://github.com/44billion/nappup.git"
7
7
  },
8
8
  "license": "MIT",
9
- "version": "2.0.0",
9
+ "version": "2.1.0",
10
10
  "description": "Nostr App Uploader",
11
11
  "type": "module",
12
12
  "scripts": {
@@ -17,11 +17,12 @@
17
17
  "nappup": "bin/nappup/index.js"
18
18
  },
19
19
  "dependencies": {
20
+ "@dotenvx/primitives": "^2.1.0",
20
21
  "@noble/curves": "^2.0.0",
21
22
  "@noble/hashes": "^2.0.0",
22
23
  "dotenv": "^17.2.0",
23
24
  "file-type": "^21.0.0",
24
- "libp2r2p": "^0.2.0",
25
+ "libp2r2p": "^0.3.0",
25
26
  "mime-types": "^3.0.1",
26
27
  "nmmr": "^2.0.0",
27
28
  "nostr-tools": "^2.23.3"
@@ -0,0 +1,64 @@
1
+ import { getPublicKey } from 'nostr-tools/pure'
2
+ import { base16ToBytes } from 'libp2r2p/base16'
3
+ import { base64UrlToBytes, bytesToBase64Url } from 'libp2r2p/base64'
4
+
5
+ const HEX_32 = /^[0-9a-f]{64}$/
6
+ const BASE64_URL = /^[A-Za-z0-9_-]+$/
7
+
8
+ export function normalizeClientKey (value) {
9
+ if (typeof value !== 'string') throw new Error('Invalid bunker client key')
10
+ const normalized = value.toLowerCase()
11
+ if (!HEX_32.test(normalized)) throw new Error('Invalid bunker client key')
12
+ try {
13
+ getPublicKey(base16ToBytes(normalized))
14
+ } catch {
15
+ throw new Error('Invalid bunker client key')
16
+ }
17
+ return normalized
18
+ }
19
+
20
+ function normalizePubkey (value) {
21
+ const normalized = typeof value === 'string' ? value.toLowerCase() : ''
22
+ if (!HEX_32.test(normalized)) throw new Error('Invalid CLI bunker session')
23
+ return normalized
24
+ }
25
+
26
+ function uniqueStrings (values) {
27
+ return [...new Set(values.filter(value => typeof value === 'string' && value))]
28
+ }
29
+
30
+ export function encodeCliSession (session) {
31
+ const value = JSON.stringify({
32
+ remoteSignerPubkey: session.remoteSignerPubkey,
33
+ relays: session.relays,
34
+ secret: session.secret,
35
+ clientKey: session.clientKey
36
+ })
37
+ return bytesToBase64Url(new TextEncoder().encode(value))
38
+ }
39
+
40
+ export function decodeCliSession (encoded) {
41
+ if (typeof encoded !== 'string' || !BASE64_URL.test(encoded)) throw new Error('Invalid CLI bunker session')
42
+ const bytes = base64UrlToBytes(encoded)
43
+ if (bytesToBase64Url(bytes) !== encoded) throw new Error('Invalid CLI bunker session')
44
+
45
+ let value
46
+ try {
47
+ value = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes))
48
+ } catch {
49
+ throw new Error('Invalid CLI bunker session')
50
+ }
51
+
52
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid CLI bunker session')
53
+ const remoteSignerPubkey = normalizePubkey(value.remoteSignerPubkey)
54
+ const relays = uniqueStrings(Array.isArray(value.relays) ? value.relays : [])
55
+ if (!relays.length) throw new Error('Invalid CLI bunker session')
56
+ let secret = null
57
+ if (value.secret !== null) {
58
+ if (typeof value.secret !== 'string' || !value.secret) throw new Error('Invalid CLI bunker session')
59
+ secret = value.secret
60
+ }
61
+ const clientKey = normalizeClientKey(value.clientKey)
62
+
63
+ return { remoteSignerPubkey, relays, secret, clientKey }
64
+ }
@@ -0,0 +1,96 @@
1
+ import { generateSecretKey } from 'nostr-tools/pure'
2
+ import { bytesToBase16 } from 'libp2r2p/base16'
3
+ import { toBunkerUrl } from 'libp2r2p/nip46'
4
+ import {
5
+ dotenvPath,
6
+ DotenvDecryptionError,
7
+ readEncryptedDotenvValue,
8
+ setEncryptedDotenvValue
9
+ } from '#services/dotenv.js'
10
+ import {
11
+ decodeCliSession,
12
+ encodeCliSession,
13
+ normalizeClientKey
14
+ } from '#services/bunker-session-codec.js'
15
+ import { parseBunkerSessionUrl } from '#services/bunker-url.js'
16
+
17
+ export const LAST_CLI_BUNKER_SESSION = 'LAST_CLI_BUNKER_SESSION'
18
+
19
+ function uniqueStrings (values) {
20
+ return [...new Set(values.filter(value => typeof value === 'string' && value))]
21
+ }
22
+
23
+ function readCliSession (filePath, onWarning) {
24
+ let encoded
25
+ try {
26
+ encoded = readEncryptedDotenvValue(LAST_CLI_BUNKER_SESSION, { filePath, onWarning })
27
+ } catch (error) {
28
+ if (error instanceof DotenvDecryptionError) throw error
29
+ onWarning?.(`Ignoring invalid ${LAST_CLI_BUNKER_SESSION}: ${error.message}`)
30
+ return null
31
+ }
32
+ if (!encoded) return null
33
+ try {
34
+ return decodeCliSession(encoded)
35
+ } catch (error) {
36
+ onWarning?.(`Ignoring invalid ${LAST_CLI_BUNKER_SESSION}: ${error.message}`)
37
+ return null
38
+ }
39
+ }
40
+
41
+ function cacheMatchesInput (cache, parsed) {
42
+ if (!cache || cache.remoteSignerPubkey !== parsed.pointer.remoteSignerPubkey) return false
43
+ if (parsed.clientKey) return cache.clientKey === parsed.clientKey
44
+ return parsed.pointer.secret ? cache.secret === parsed.pointer.secret : true
45
+ }
46
+
47
+ export function persistBunkerSession (session) {
48
+ if (session.source === 'dotenv') {
49
+ const url = new URL(toBunkerUrl({
50
+ remoteSignerPubkey: session.remoteSignerPubkey,
51
+ relays: session.relays,
52
+ secret: session.secret
53
+ }))
54
+ url.hash = new URLSearchParams({ client_key: session.clientKey }).toString()
55
+ setEncryptedDotenvValue('NOSTR_SECRET_KEY', url.toString(), {
56
+ filePath: session.filePath,
57
+ updateProcessEnv: true
58
+ })
59
+ return
60
+ }
61
+
62
+ setEncryptedDotenvValue(LAST_CLI_BUNKER_SESSION, encodeCliSession(session), {
63
+ filePath: session.filePath,
64
+ updateProcessEnv: true
65
+ })
66
+ }
67
+
68
+ export { parseBunkerSessionUrl } from '#services/bunker-url.js'
69
+
70
+ export function prepareBunkerSession (bunkerUrl, {
71
+ source = 'cli',
72
+ filePath = dotenvPath,
73
+ onWarning = console.warn,
74
+ generateClientKey = generateSecretKey
75
+ } = {}) {
76
+ if (source !== 'cli' && source !== 'dotenv') throw new Error('Invalid bunker session source')
77
+ const parsed = parseBunkerSessionUrl(bunkerUrl)
78
+ const cache = source === 'cli' ? readCliSession(filePath, onWarning) : null
79
+ const matchingCache = cacheMatchesInput(cache, parsed) ? cache : null
80
+
81
+ let clientKey = parsed.clientKey || matchingCache?.clientKey || null
82
+ const reusedClientKey = Boolean(clientKey)
83
+ if (!clientKey) clientKey = normalizeClientKey(bytesToBase16(generateClientKey()))
84
+
85
+ const session = {
86
+ source,
87
+ filePath,
88
+ remoteSignerPubkey: parsed.pointer.remoteSignerPubkey,
89
+ relays: uniqueStrings([...parsed.pointer.relays, ...(matchingCache?.relays || [])]),
90
+ secret: parsed.pointer.secret || matchingCache?.secret || null,
91
+ clientKey,
92
+ reusedClientKey
93
+ }
94
+ persistBunkerSession(session)
95
+ return session
96
+ }
@@ -1,8 +1,69 @@
1
- import { generateSecretKey } from 'nostr-tools/pure'
2
- import { BunkerSigner, parseBunkerInput } from 'nostr-tools/nip46'
1
+ import { BunkerSigner } from 'libp2r2p/nip46'
2
+ import { base16ToBytes } from 'libp2r2p/base16'
3
3
  import { getRelays } from '#helpers/signer.js'
4
+ import {
5
+ persistBunkerSession,
6
+ prepareBunkerSession
7
+ } from '#services/bunker-session.js'
4
8
 
5
9
  const createToken = Symbol('createToken')
10
+ const DEFAULT_CONNECT_TIMEOUT = 5_000
11
+ const DEFAULT_REQUEST_TIMEOUT = 30_000
12
+
13
+ function alreadyConnected (error) {
14
+ return /already connected/i.test(typeof error === 'string' ? error : error?.message || '')
15
+ }
16
+
17
+ async function closeQuietly (bunker) {
18
+ try {
19
+ await bunker?.close()
20
+ } catch {}
21
+ }
22
+
23
+ async function runWithDeadline (operation, timeout, timeoutMessage, onFailure) {
24
+ const controller = new AbortController()
25
+ let timer
26
+ const deadline = new Promise((resolve, reject) => {
27
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), timeout)
28
+ })
29
+ const pending = Promise.resolve().then(() => operation(controller.signal))
30
+
31
+ try {
32
+ return await Promise.race([pending, deadline])
33
+ } catch (error) {
34
+ controller.abort()
35
+ await onFailure?.()
36
+ await pending.catch(() => {})
37
+ throw error
38
+ } finally {
39
+ clearTimeout(timer)
40
+ }
41
+ }
42
+
43
+ async function openBunker (session, secret, {
44
+ bunkerFactory,
45
+ connectTimeout
46
+ }) {
47
+ const bunker = bunkerFactory(base16ToBytes(session.clientKey), {
48
+ remoteSignerPubkey: session.remoteSignerPubkey,
49
+ relays: session.relays,
50
+ secret
51
+ })
52
+
53
+ await runWithDeadline(async signal => {
54
+ try {
55
+ await bunker.connect({
56
+ clientMetadata: { name: 'nappup' },
57
+ signal
58
+ })
59
+ } catch (error) {
60
+ if (!alreadyConnected(error)) throw error
61
+ await bunker.getPublicKey({ signal })
62
+ }
63
+ }, connectTimeout, 'Bunker connection timed out', () => closeQuietly(bunker))
64
+
65
+ return bunker
66
+ }
6
67
 
7
68
  export default class NostrBunkerSigner {
8
69
  #bunker
@@ -14,28 +75,53 @@ export default class NostrBunkerSigner {
14
75
  this.#publicKey = publicKey
15
76
  }
16
77
 
17
- static async create (bunkerUrl, { connectTimeout = 30_000 } = {}) {
18
- const bp = await parseBunkerInput(bunkerUrl)
19
- if (!bp) throw new Error('Invalid bunker URL')
20
- if (bp.relays.length === 0) throw new Error('Bunker URL must include at least one relay (?relay=wss://...)')
78
+ static async create (bunkerUrl, {
79
+ source = 'cli',
80
+ dotenvFilePath,
81
+ connectTimeout = DEFAULT_CONNECT_TIMEOUT,
82
+ requestTimeout = DEFAULT_REQUEST_TIMEOUT,
83
+ onWarning = console.warn,
84
+ generateClientKey,
85
+ bunkerFactory = (clientKey, pointer) => BunkerSigner.fromBunker(clientKey, pointer)
86
+ } = {}) {
87
+ const session = prepareBunkerSession(bunkerUrl, {
88
+ source,
89
+ ...(dotenvFilePath ? { filePath: dotenvFilePath } : {}),
90
+ onWarning,
91
+ ...(generateClientKey ? { generateClientKey } : {})
92
+ })
21
93
 
22
- const clientSk = generateSecretKey()
23
- const bunker = BunkerSigner.fromBunker(clientSk, bp)
24
-
25
- let timeoutHandle
94
+ let bunker
26
95
  try {
27
- await Promise.race([
28
- bunker.connect(),
29
- new Promise((resolve, reject) => {
30
- timeoutHandle = setTimeout(() => reject(new Error('Bunker connection timed out')), connectTimeout)
31
- })
32
- ])
33
- } finally {
34
- clearTimeout(timeoutHandle)
35
- }
96
+ if (session.reusedClientKey) {
97
+ try {
98
+ bunker = await openBunker(session, null, { bunkerFactory, connectTimeout })
99
+ } catch (error) {
100
+ if (!session.secret) throw error
101
+ bunker = await openBunker(session, session.secret, { bunkerFactory, connectTimeout })
102
+ }
103
+ } else {
104
+ bunker = await openBunker(session, session.secret, { bunkerFactory, connectTimeout })
105
+ }
36
106
 
37
- const publicKey = await bunker.getPublicKey()
38
- return new this(createToken, bunker, publicKey)
107
+ const publicKey = await runWithDeadline(
108
+ signal => bunker.getPublicKey({ timeout: requestTimeout, signal }),
109
+ requestTimeout,
110
+ 'Bunker public key request timed out',
111
+ () => closeQuietly(bunker)
112
+ )
113
+
114
+ session.relays = [...bunker.pointer.relays]
115
+ try {
116
+ persistBunkerSession(session)
117
+ } catch (error) {
118
+ onWarning?.(`Could not persist updated bunker session: ${error.message}`)
119
+ }
120
+ return new this(createToken, bunker, publicKey)
121
+ } catch (error) {
122
+ await closeQuietly(bunker)
123
+ throw error
124
+ }
39
125
  }
40
126
 
41
127
  getPublicKey () {
@@ -0,0 +1,32 @@
1
+ import { parseBunkerUrl } from 'libp2r2p/nip46'
2
+ import { normalizeClientKey } from '#services/bunker-session-codec.js'
3
+
4
+ export function parseBunkerSessionUrl (input) {
5
+ let url
6
+ try {
7
+ url = new URL(input)
8
+ } catch {
9
+ throw new Error('Invalid bunker URL')
10
+ }
11
+ if (url.protocol !== 'bunker:') throw new Error('Invalid bunker URL')
12
+ if (url.searchParams.getAll('secret').length > 1) throw new Error('Bunker URL must not contain multiple secrets')
13
+ if (url.searchParams.getAll('relay').length === 0) {
14
+ throw new Error('Bunker URL must include at least one relay (?relay=wss://...)')
15
+ }
16
+
17
+ let clientKey = null
18
+ if (url.hash) {
19
+ const fragment = new URLSearchParams(url.hash.slice(1))
20
+ const keys = [...fragment.keys()]
21
+ const values = fragment.getAll('client_key')
22
+ if (keys.some(key => key !== 'client_key') || values.length !== 1) {
23
+ throw new Error('Invalid bunker URL fragment')
24
+ }
25
+ clientKey = normalizeClientKey(values[0])
26
+ url.hash = ''
27
+ }
28
+
29
+ const pointer = parseBunkerUrl(url.toString())
30
+ if (!pointer) throw new Error('Invalid bunker URL')
31
+ return { pointer, clientKey }
32
+ }
@@ -0,0 +1,356 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import * as dotenv from 'dotenv'
5
+ import { decrypt, derive, encrypt } from '@dotenvx/primitives'
6
+ import { base64ToBytes, bytesToBase64 } from 'libp2r2p/base64'
7
+ import { decodeCliSession } from '#services/bunker-session-codec.js'
8
+ import { validateNostrSecretKey } from '#services/nostr-secret-key.js'
9
+
10
+ export const DOTENV_PRIVATE_KEY_NAME = 'DOTENV_PRIVATE_KEY_NAPPUP'
11
+ export const DOTENV_PUBLIC_KEY_NAME = 'DOTENV_PUBLIC_KEY_NAPPUP'
12
+ export const LAST_CLI_BUNKER_SESSION = 'LAST_CLI_BUNKER_SESSION'
13
+ export const DEFAULT_DOTENV_PRIVATE_KEY = '0000000000000000000000000000000000000000000000000000000000000001'
14
+ export const dotenvPath = path.resolve(process.env.DOTENV_CONFIG_PATH ?? path.join(process.cwd(), '.env'))
15
+
16
+ const MANAGED_NAMES = new Set(['NOSTR_SECRET_KEY', LAST_CLI_BUNKER_SESSION])
17
+ const ENCRYPTED_PREFIX = 'encrypted:'
18
+ const MIN_ECIES_PAYLOAD_BYTES = 97
19
+
20
+ let configuration = {
21
+ privateKey: DEFAULT_DOTENV_PRIVATE_KEY,
22
+ privateKeyExplicit: false,
23
+ externalPublicKey: null,
24
+ processEnv: process.env,
25
+ initialized: false
26
+ }
27
+
28
+ export class DotenvDecryptionError extends Error {
29
+ constructor (name, cause) {
30
+ super(`Unable to decrypt ${name} with the selected dotenv private key`, { cause })
31
+ this.name = 'DotenvDecryptionError'
32
+ this.code = 'DOTENV_DECRYPTION_FAILED'
33
+ }
34
+ }
35
+
36
+ function normalizePrivateKey (value) {
37
+ const normalized = typeof value === 'string' ? value.toLowerCase() : ''
38
+ if (!/^[0-9a-f]{64}$/.test(normalized)) throw new Error('Invalid dotenv private key')
39
+ try {
40
+ derive(normalized)
41
+ } catch {
42
+ throw new Error('Invalid dotenv private key')
43
+ }
44
+ return normalized
45
+ }
46
+
47
+ function normalizePublicKey (value) {
48
+ const normalized = typeof value === 'string' ? value.toLowerCase() : ''
49
+ if (!/^(02|03)[0-9a-f]{64}$/.test(normalized)) throw new Error('Invalid dotenv public key')
50
+ try {
51
+ encrypt(normalized, '')
52
+ } catch {
53
+ throw new Error('Invalid dotenv public key')
54
+ }
55
+ return normalized
56
+ }
57
+
58
+ function isEncrypted (value) {
59
+ return typeof value === 'string' && value.startsWith(ENCRYPTED_PREFIX)
60
+ }
61
+
62
+ function validateEncryptedValue (value) {
63
+ if (!isEncrypted(value)) throw new Error('Invalid encrypted dotenv value')
64
+ const encoded = value.slice(ENCRYPTED_PREFIX.length)
65
+ if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) throw new Error('Invalid encrypted dotenv value')
66
+ let bytes
67
+ try {
68
+ bytes = base64ToBytes(encoded)
69
+ } catch {
70
+ throw new Error('Invalid encrypted dotenv value')
71
+ }
72
+ if (bytes.length < MIN_ECIES_PAYLOAD_BYTES || bytesToBase64(bytes) !== encoded) {
73
+ throw new Error('Invalid encrypted dotenv value')
74
+ }
75
+ }
76
+
77
+ function validateManagedValue (name, value) {
78
+ if (name === 'NOSTR_SECRET_KEY') return validateNostrSecretKey(value)
79
+ if (name === LAST_CLI_BUNKER_SESSION) {
80
+ decodeCliSession(value)
81
+ return value
82
+ }
83
+ throw new Error(`Unsupported managed dotenv variable: ${name}`)
84
+ }
85
+
86
+ export function readDotenvValues (filePath = dotenvPath) {
87
+ try {
88
+ return dotenv.parse(fs.readFileSync(filePath))
89
+ } catch (error) {
90
+ if (error?.code === 'ENOENT') return {}
91
+ throw error
92
+ }
93
+ }
94
+
95
+ function readDotenvFile (filePath) {
96
+ try {
97
+ return {
98
+ contents: fs.readFileSync(filePath, 'utf8'),
99
+ mode: fs.statSync(filePath).mode & 0o777
100
+ }
101
+ } catch (error) {
102
+ if (error?.code === 'ENOENT') return { contents: '', mode: 0o600 }
103
+ throw error
104
+ }
105
+ }
106
+
107
+ function writeDotenvChanges (changes, { filePath = dotenvPath } = {}) {
108
+ const { contents, mode } = readDotenvFile(filePath)
109
+ const eol = contents.includes('\r\n') ? '\r\n' : '\n'
110
+ const lines = contents ? contents.split(/\r?\n/) : []
111
+ if (lines.at(-1) === '') lines.pop()
112
+
113
+ const names = [...changes.keys()]
114
+ const assignments = new Map(names.map(name => [
115
+ name,
116
+ new RegExp(`^\\s*(?:export\\s+)?${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*=`)
117
+ ]))
118
+ const written = new Set()
119
+ const nextLines = []
120
+
121
+ for (const line of lines) {
122
+ const name = names.find(candidate => assignments.get(candidate).test(line))
123
+ if (!name) {
124
+ nextLines.push(line)
125
+ continue
126
+ }
127
+ if (written.has(name)) continue
128
+ written.add(name)
129
+ const value = changes.get(name)
130
+ if (value !== null) nextLines.push(`${name}=${JSON.stringify(value)}`)
131
+ }
132
+ for (const [name, value] of changes) {
133
+ if (!written.has(name) && value !== null) nextLines.push(`${name}=${JSON.stringify(value)}`)
134
+ }
135
+
136
+ const nextContents = `${nextLines.join(eol)}${eol}`
137
+ const directory = path.dirname(filePath)
138
+ const temporaryPath = path.join(directory, `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`)
139
+ try {
140
+ fs.writeFileSync(temporaryPath, nextContents, { encoding: 'utf8', flag: 'wx', mode })
141
+ fs.chmodSync(temporaryPath, mode)
142
+ fs.renameSync(temporaryPath, filePath)
143
+ } finally {
144
+ try {
145
+ fs.unlinkSync(temporaryPath)
146
+ } catch {}
147
+ }
148
+ }
149
+
150
+ function decryptManagedValue (name, value, privateKey) {
151
+ try {
152
+ validateEncryptedValue(value)
153
+ return decrypt(privateKey, value)
154
+ } catch (error) {
155
+ throw new DotenvDecryptionError(name, error)
156
+ }
157
+ }
158
+
159
+ function warnKeyMismatch (names, onWarning, replacingName) {
160
+ const details = []
161
+ if (replacingName) details.push(`${replacingName} will be replaced by the supplied value`)
162
+ if (names.includes('NOSTR_SECRET_KEY')) {
163
+ details.push('a new publisher identity will be generated unless -s or an environment NOSTR_SECRET_KEY is provided')
164
+ }
165
+ if (names.includes(LAST_CLI_BUNKER_SESSION)) {
166
+ details.push('the next CLI bunker connection will generate a new client key and may require authorization')
167
+ }
168
+ const discarded = names.length ? ` Inaccessible values reset: ${names.join(', ')}.` : ''
169
+ const consequences = details.length ? ` ${details.join('; ')}.` : ''
170
+ onWarning?.(`The explicit dotenv private key does not match the stored public key; adopting the explicit key.${discarded}${consequences}`)
171
+ }
172
+
173
+ function reconcileExplicitPrivateKey (filePath, onWarning, replacement) {
174
+ if (!configuration.privateKeyExplicit) return false
175
+ const values = readDotenvValues(filePath)
176
+ const derivedPublicKey = derive(configuration.privateKey)
177
+ const existingPublicValue = values[DOTENV_PUBLIC_KEY_NAME]
178
+ if (!existingPublicValue) return false
179
+
180
+ let existingPublicKey
181
+ try {
182
+ existingPublicKey = normalizePublicKey(existingPublicValue)
183
+ } catch {
184
+ existingPublicKey = null
185
+ }
186
+ if (existingPublicKey === derivedPublicKey) {
187
+ if (existingPublicValue !== derivedPublicKey) {
188
+ writeDotenvChanges(new Map([[DOTENV_PUBLIC_KEY_NAME, derivedPublicKey]]), { filePath })
189
+ }
190
+ return false
191
+ }
192
+
193
+ const fallbackPublicKey = derive(DEFAULT_DOTENV_PRIVATE_KEY)
194
+ const fallbackUpgrade = existingPublicKey === fallbackPublicKey
195
+ const changes = new Map([[DOTENV_PUBLIC_KEY_NAME, derivedPublicKey]])
196
+ const discarded = []
197
+
198
+ for (const name of MANAGED_NAMES) {
199
+ const stored = values[name]
200
+ if (stored === undefined) continue
201
+ if (replacement?.name === name) continue
202
+ let plaintext
203
+ try {
204
+ if (isEncrypted(stored)) {
205
+ try {
206
+ plaintext = decryptManagedValue(name, stored, configuration.privateKey)
207
+ } catch (error) {
208
+ if (!fallbackUpgrade) throw error
209
+ plaintext = decryptManagedValue(name, stored, DEFAULT_DOTENV_PRIVATE_KEY)
210
+ }
211
+ } else {
212
+ plaintext = stored
213
+ }
214
+ validateManagedValue(name, plaintext)
215
+ changes.set(name, encrypt(derivedPublicKey, plaintext))
216
+ } catch (error) {
217
+ if (fallbackUpgrade) throw error
218
+ changes.set(name, null)
219
+ discarded.push(name)
220
+ }
221
+ }
222
+
223
+ if (replacement) changes.set(replacement.name, encrypt(derivedPublicKey, replacement.value))
224
+ if (!fallbackUpgrade) warnKeyMismatch(discarded, onWarning, replacement?.name)
225
+ writeDotenvChanges(changes, { filePath })
226
+ return true
227
+ }
228
+
229
+ function getPublicKeyForFile (filePath) {
230
+ const values = readDotenvValues(filePath)
231
+ const stored = values[DOTENV_PUBLIC_KEY_NAME]
232
+ if (stored) return normalizePublicKey(stored)
233
+ if (configuration.externalPublicKey) return configuration.externalPublicKey
234
+ return derive(configuration.privateKey)
235
+ }
236
+
237
+ export function setDotenvValue (name, value, {
238
+ filePath = dotenvPath,
239
+ updateProcessEnv = true
240
+ } = {}) {
241
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) throw new Error('Invalid dotenv variable name')
242
+ if (MANAGED_NAMES.has(name)) throw new Error(`Use setEncryptedDotenvValue for ${name}`)
243
+ if (typeof value !== 'string') throw new TypeError('Dotenv value should be a string')
244
+ writeDotenvChanges(new Map([[name, value]]), { filePath })
245
+ if (updateProcessEnv) configuration.processEnv[name] = value
246
+ }
247
+
248
+ export function setEncryptedDotenvValue (name, value, {
249
+ filePath = dotenvPath,
250
+ updateProcessEnv = true,
251
+ onWarning = console.warn
252
+ } = {}) {
253
+ if (!MANAGED_NAMES.has(name)) throw new Error(`Unsupported managed dotenv variable: ${name}`)
254
+ if (typeof value !== 'string') throw new TypeError('Dotenv value should be a string')
255
+ validateManagedValue(name, value)
256
+ const reconciled = reconcileExplicitPrivateKey(filePath, onWarning, { name, value })
257
+ if (reconciled) {
258
+ if (updateProcessEnv) configuration.processEnv[name] = value
259
+ return
260
+ }
261
+ const publicKey = getPublicKeyForFile(filePath)
262
+ writeDotenvChanges(new Map([
263
+ [DOTENV_PUBLIC_KEY_NAME, publicKey],
264
+ [name, encrypt(publicKey, value)]
265
+ ]), { filePath })
266
+ if (updateProcessEnv) configuration.processEnv[name] = value
267
+ }
268
+
269
+ export function readEncryptedDotenvValue (name, {
270
+ filePath = dotenvPath,
271
+ migratePlaintext = true,
272
+ onWarning = console.warn
273
+ } = {}) {
274
+ if (!MANAGED_NAMES.has(name)) throw new Error(`Unsupported managed dotenv variable: ${name}`)
275
+ reconcileExplicitPrivateKey(filePath, onWarning)
276
+ const values = readDotenvValues(filePath)
277
+ const stored = values[name]
278
+ if (stored === undefined) return null
279
+
280
+ if (!isEncrypted(stored)) {
281
+ validateManagedValue(name, stored)
282
+ if (migratePlaintext) {
283
+ setEncryptedDotenvValue(name, stored, { filePath, updateProcessEnv: false, onWarning })
284
+ }
285
+ return stored
286
+ }
287
+
288
+ const publicKey = values[DOTENV_PUBLIC_KEY_NAME]
289
+ ? normalizePublicKey(values[DOTENV_PUBLIC_KEY_NAME])
290
+ : derive(configuration.privateKey)
291
+ if (derive(configuration.privateKey) !== publicKey) {
292
+ throw new DotenvDecryptionError(name, new Error('The selected private key does not match DOTENV_PUBLIC_KEY_NAPPUP'))
293
+ }
294
+ const plaintext = decryptManagedValue(name, stored, configuration.privateKey)
295
+ validateManagedValue(name, plaintext)
296
+ return plaintext
297
+ }
298
+
299
+ export function initializeDotenv ({
300
+ privateKey,
301
+ filePath = dotenvPath,
302
+ processEnv = process.env,
303
+ loadNostrSecretKey = true,
304
+ reconcilePrivateKey = true,
305
+ onWarning = console.warn
306
+ } = {}) {
307
+ const hadNostrSecretKey = Object.hasOwn(processEnv, 'NOSTR_SECRET_KEY')
308
+ const environmentPrivateKey = Object.hasOwn(processEnv, DOTENV_PRIVATE_KEY_NAME)
309
+ ? processEnv[DOTENV_PRIVATE_KEY_NAME]
310
+ : null
311
+ const environmentPublicKey = Object.hasOwn(processEnv, DOTENV_PUBLIC_KEY_NAME)
312
+ ? processEnv[DOTENV_PUBLIC_KEY_NAME]
313
+ : null
314
+ const selectedPrivateKey = normalizePrivateKey(privateKey ?? environmentPrivateKey ?? DEFAULT_DOTENV_PRIVATE_KEY)
315
+
316
+ const initialValues = readDotenvValues(filePath)
317
+ if (Object.hasOwn(initialValues, DOTENV_PRIVATE_KEY_NAME)) {
318
+ throw new Error(`${DOTENV_PRIVATE_KEY_NAME} must not be stored in ${filePath}`)
319
+ }
320
+
321
+ configuration = {
322
+ privateKey: selectedPrivateKey,
323
+ privateKeyExplicit: (privateKey !== undefined && privateKey !== null) || environmentPrivateKey !== null,
324
+ externalPublicKey: environmentPublicKey ? normalizePublicKey(environmentPublicKey) : null,
325
+ processEnv,
326
+ initialized: true
327
+ }
328
+
329
+ if (reconcilePrivateKey) reconcileExplicitPrivateKey(filePath, onWarning)
330
+ const values = readDotenvValues(filePath)
331
+ for (const [name, value] of Object.entries(values)) {
332
+ if (name === DOTENV_PRIVATE_KEY_NAME || MANAGED_NAMES.has(name)) continue
333
+ if (!Object.hasOwn(processEnv, name)) processEnv[name] = value
334
+ }
335
+
336
+ let nostrSecretKeySource = hadNostrSecretKey ? 'process' : null
337
+ if (!hadNostrSecretKey && loadNostrSecretKey) {
338
+ const value = readEncryptedDotenvValue('NOSTR_SECRET_KEY', { filePath, onWarning })
339
+ if (value !== null) {
340
+ processEnv.NOSTR_SECRET_KEY = value
341
+ nostrSecretKeySource = 'dotenv'
342
+ }
343
+ }
344
+
345
+ return {
346
+ filePath,
347
+ nostrSecretKeySource,
348
+ privateKeyExplicit: configuration.privateKeyExplicit,
349
+ publicKey: getPublicKeyForFile(filePath)
350
+ }
351
+ }
352
+
353
+ export function ensureDotenvInitialized (options) {
354
+ if (!configuration.initialized) return initializeDotenv(options)
355
+ return null
356
+ }
@@ -0,0 +1,24 @@
1
+ import { getPublicKey } from 'nostr-tools/pure'
2
+ import { base16ToBytes } from 'libp2r2p/base16'
3
+ import { nsecDecode } from 'libp2r2p/nip19'
4
+ import { parseBunkerSessionUrl } from '#services/bunker-url.js'
5
+
6
+ const HEX_32 = /^[0-9a-f]{64}$/i
7
+
8
+ export function validateNostrSecretKey (value) {
9
+ if (typeof value !== 'string' || !value) throw new Error('Invalid NOSTR_SECRET_KEY')
10
+ if (value.startsWith('bunker://')) {
11
+ parseBunkerSessionUrl(value)
12
+ return value
13
+ }
14
+
15
+ let hex = value
16
+ try {
17
+ if (value.startsWith('nsec1')) hex = nsecDecode(value)
18
+ if (!HEX_32.test(hex)) throw new Error()
19
+ getPublicKey(base16ToBytes(hex))
20
+ } catch {
21
+ throw new Error('Invalid NOSTR_SECRET_KEY')
22
+ }
23
+ return value
24
+ }
@@ -1,7 +1,3 @@
1
- import fs from 'node:fs'
2
- import path from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
- import * as dotenv from 'dotenv'
5
1
  import { getPublicKey } from 'nostr-tools/pure'
6
2
  import { getConversationKey, encrypt, decrypt } from 'nostr-tools/nip44'
7
3
  import nostrRelays, { seedRelays, freeRelays } from '#services/nostr-relays.js'
@@ -9,13 +5,7 @@ import { getRelays } from '#helpers/signer.js'
9
5
  import { bytesToBase16, base16ToBytes } from '#helpers/base16.js'
10
6
  import { finalizeEvent } from '#helpers/nip01.js'
11
7
  import { nsecDecode, nsecEncode } from 'libp2r2p/nip19'
12
- const __dirname = fileURLToPath(new URL('.', import.meta.url))
13
-
14
- const dotenvPath = process.env.DOTENV_CONFIG_PATH ?? `${__dirname}/../../.env`
15
- dotenv.config({
16
- path: dotenvPath,
17
- quiet: true
18
- })
8
+ import { ensureDotenvInitialized, setEncryptedDotenvValue } from '#services/dotenv.js'
19
9
 
20
10
  const nip44 = {
21
11
  getConversationKey,
@@ -42,6 +32,7 @@ export default class NostrSigner {
42
32
  return new this(createToken, base16ToBytes(sk))
43
33
  }
44
34
 
35
+ ensureDotenvInitialized()
45
36
  let skBytes
46
37
  let isNewSk = false
47
38
  if (process.env.NOSTR_SECRET_KEY) {
@@ -52,7 +43,7 @@ export default class NostrSigner {
52
43
  } else {
53
44
  isNewSk = true
54
45
  sk = generateSecretKey()
55
- fs.appendFileSync(path.resolve(dotenvPath), `NOSTR_SECRET_KEY=${nsecEncode(sk)}\n`)
46
+ setEncryptedDotenvValue('NOSTR_SECRET_KEY', nsecEncode(sk))
56
47
  skBytes = base16ToBytes(sk)
57
48
  }
58
49
  const ret = new this(createToken, skBytes)
@@ -10,6 +10,7 @@ const MANAGED_MANIFEST_TAGS = new Set([
10
10
  export function normalizeManifestPath (value) {
11
11
  if (typeof value !== 'string') throw new TypeError('Manifest path must be a string')
12
12
  const path = value.startsWith('/') ? value.slice(1) : value
13
+ // eslint-disable-next-line no-control-regex
13
14
  if (!path || path.includes('\\') || /[\u0000-\u001f\u007f]/.test(path)) {
14
15
  throw new Error(`Unsafe manifest path: ${JSON.stringify(value)}`)
15
16
  }
@@ -1,74 +0,0 @@
1
- import { base16ToBytes, bytesToBase16 } from '#helpers/base16.js'
2
- import { base62ToBytes, bytesToBase62 } from '#helpers/base62.js'
3
-
4
- export const BASE36_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
5
- const BASE = BigInt(BASE36_ALPHABET.length)
6
- const LEADER = BASE36_ALPHABET[0]
7
- const CHAR_MAP = new Map([...BASE36_ALPHABET].map((char, index) => [char, BigInt(index)]))
8
- const BASE36_REGEX = /^[0-9a-z]+$/
9
-
10
- export function isBase36 (str) {
11
- if (typeof str !== 'string') return false
12
- return BASE36_REGEX.test(str)
13
- }
14
-
15
- export function bytesToBase36 (bytes, padLength = 0) {
16
- return base16ToBase36(bytesToBase16(bytes), padLength)
17
- }
18
-
19
- export function base16ToBase36 (hex, padLength = 0) {
20
- return bigIntToBase36(base16ToBigInt(hex), padLength)
21
- }
22
-
23
- export function base62ToBase36 (base62str, padLength = 0) {
24
- return bytesToBase36(base62ToBytes(base62str), padLength)
25
- }
26
-
27
- export function base36ToBytes (base36str) {
28
- return base16ToBytes(base36ToBase16(base36str))
29
- }
30
-
31
- export function base36ToBase16 (base36str) {
32
- return bigIntToBase16(base36ToBigInt(base36str))
33
- }
34
-
35
- export function base36ToBase62 (base36str, padLength = 0) {
36
- return bytesToBase62(base36ToBytes(base36str), padLength)
37
- }
38
-
39
- function base36ToBigInt (base36str) {
40
- if (typeof base36str !== 'string') {
41
- throw new Error('Input must be a string.')
42
- }
43
-
44
- let result = 0n
45
- for (const char of base36str) {
46
- const value = CHAR_MAP.get(char)
47
- if (value === undefined) {
48
- throw new Error(`Invalid character in Base36 string: ${char}`)
49
- }
50
- result = result * BASE + value
51
- }
52
- return result
53
- }
54
-
55
- function bigIntToBase36 (num, padLength) {
56
- if (typeof num !== 'bigint') throw new Error('Input must be a BigInt.')
57
- if (num < 0n) throw new Error('Can\'t be signed BigInt')
58
-
59
- return num.toString(36).padStart(padLength, LEADER)
60
- }
61
-
62
- function bigIntToBase16 (num) {
63
- if (typeof num !== 'bigint') throw new Error('Input must be a BigInt.')
64
- if (num < 0n) throw new Error('Can\'t be signed BigInt')
65
-
66
- let hexString = num.toString(16)
67
- if (hexString.length % 2 !== 0) hexString = `0${hexString}`
68
- return hexString
69
- }
70
-
71
- function base16ToBigInt (hex) {
72
- if (typeof hex !== 'string') throw new Error('Input must be a string.')
73
- return BigInt(`0x${hex}`)
74
- }
@@ -1,59 +0,0 @@
1
- export const BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
2
- const BASE = BigInt(BASE62_ALPHABET.length)
3
- const LEADER = BASE62_ALPHABET[0]
4
- const CHAR_MAP = new Map([...BASE62_ALPHABET].map((char, index) => [char, BigInt(index)]))
5
-
6
- export function bytesToBase62 (bytes, padLength = 0) {
7
- if (bytes.length === 0) return ''.padStart(padLength, LEADER)
8
-
9
- let num = 0n
10
- for (const byte of bytes) {
11
- num = (num << 8n) + BigInt(byte)
12
- }
13
-
14
- let result = ''
15
- if (num === 0n) return LEADER.padStart(padLength, LEADER)
16
-
17
- while (num > 0n) {
18
- const remainder = num % BASE
19
- result = BASE62_ALPHABET[Number(remainder)] + result
20
- num = num / BASE
21
- }
22
-
23
- for (const byte of bytes) {
24
- if (byte !== 0) break
25
-
26
- result = LEADER + result
27
- }
28
-
29
- return result.padStart(padLength, LEADER)
30
- }
31
-
32
- export function base62ToBytes (base62Str) {
33
- if (typeof base62Str !== 'string') { throw new Error('base62ToBytes requires a string argument') }
34
- if (base62Str.length === 0) return new Uint8Array()
35
-
36
- let leadingZeros = 0
37
- for (let i = 0; i < base62Str.length; i++) {
38
- if (base62Str[i] !== LEADER) break
39
-
40
- leadingZeros++
41
- }
42
-
43
- let num = 0n
44
- for (const char of base62Str) {
45
- const value = CHAR_MAP.get(char)
46
- if (value === undefined) { throw new Error(`Invalid character in Base62 string: ${char}`) }
47
- num = num * BASE + value
48
- }
49
-
50
- const bytes = []
51
- while (num > 0n) {
52
- bytes.unshift(Number(num & 0xffn))
53
- num = num >> 8n
54
- }
55
-
56
- const result = new Uint8Array(leadingZeros + bytes.length)
57
- result.set(bytes, leadingZeros)
58
- return result
59
- }