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 +33 -0
- package/bin/nappup/helpers.js +108 -1
- package/bin/nappup/index.js +65 -31
- package/package.json +3 -2
- package/src/services/bunker-session-codec.js +64 -0
- package/src/services/bunker-session.js +96 -0
- package/src/services/bunker-signer.js +107 -21
- package/src/services/bunker-url.js +32 -0
- package/src/services/dotenv.js +356 -0
- package/src/services/nostr-secret-key.js +24 -0
- package/src/services/nostr-signer.js +3 -12
- package/src/services/site-manifest.js +1 -0
- package/src/helpers/base36.js +0 -74
- package/src/helpers/base62.js +0 -59
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:
|
package/bin/nappup/helpers.js
CHANGED
|
@@ -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) {
|
package/bin/nappup/index.js
CHANGED
|
@@ -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
|
-
|
|
16
|
-
const {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
41
|
+
async function upload (args, dotenvState) {
|
|
42
|
+
let { dTag } = args
|
|
43
|
+
const { dir, sk, channel, shouldReupload } = args
|
|
34
44
|
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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.
|
|
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.
|
|
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 {
|
|
2
|
-
import {
|
|
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, {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
const bunker = BunkerSigner.fromBunker(clientSk, bp)
|
|
24
|
-
|
|
25
|
-
let timeoutHandle
|
|
94
|
+
let bunker
|
|
26
95
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/helpers/base36.js
DELETED
|
@@ -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
|
-
}
|
package/src/helpers/base62.js
DELETED
|
@@ -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
|
-
}
|