-
Notifications
You must be signed in to change notification settings - Fork 8
/
station-id.js
222 lines (201 loc) · 7.15 KB
/
station-id.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
import assert from 'node:assert'
import fs from 'node:fs/promises'
import path from 'node:path'
import { subtle, getRandomValues } from 'node:crypto'
/**
* @param {object} args
* @param {string} args.secretsDir
* @param {string} args.passphrase
* @param {import('node:console')} [args.log]
* @returns {Promise<{publicKey: string, privateKey: string}>}
*/
export async function getStationId ({ secretsDir, passphrase, log = console }) {
assert.strictEqual(typeof secretsDir, 'string', 'secretsDir must be a string')
await fs.mkdir(secretsDir, { recursive: true })
const keystore = path.join(secretsDir, 'station_id')
try {
const keypair = await loadKeypair(keystore, passphrase, { log })
log.error('Loaded Station ID: %s', keypair.publicKey)
return keypair
} catch (err) {
if (err.code === 'ENOENT' && err.path === keystore) {
// the keystore file does not exist, create a new key
return await generateKeypair(keystore, passphrase, { log })
} else {
throw new Error(
`Cannot load Station ID from file "${keystore}". ${err.message}`,
{ cause: err }
)
}
}
}
/**
* @param {string} keystore
* @param {string} passphrase
* @param {object} args
* @param {import('node:console')} args.log
* @returns {Promise<{publicKey: string, privateKey: string}>}
*/
async function loadKeypair (keystore, passphrase, { log }) {
const ciphertext = await fs.readFile(keystore)
let plaintext
if (!passphrase) {
plaintext = ciphertext
} else {
const looksLikeJson =
ciphertext[0] === '{'.charCodeAt(0) &&
ciphertext[ciphertext.length - 1] === '}'.charCodeAt(0)
if (looksLikeJson) {
const keypair = await tryUpgradePlaintextToCiphertext(passphrase, keystore, ciphertext, { log })
if (keypair) return keypair
// fall back and continue the original path to decrypt the file
}
try {
plaintext = await decrypt(passphrase, ciphertext)
} catch (err) {
throw new Error(
'Cannot decrypt Station ID file. Did you configure the correct PASSPHRASE?',
{ cause: err }
)
}
}
return parseStoredKeys(plaintext)
}
/**
* @param {string} keystore
* @param {string} passphrase
* @param {Buffer} maybeCiphertext
* @param {object} args
* @param {import('node:console')} args.log
* @returns
*/
async function tryUpgradePlaintextToCiphertext (passphrase, keystore, maybeCiphertext, { log }) {
let keypair
try {
keypair = parseStoredKeys(maybeCiphertext)
} catch (err) {
// the file seems to be encrypted
return undefined
}
// re-create the keypair file with encrypted keypair
await storeKeypair(passphrase, keystore, keypair)
log.error('Encrypted the Station ID file using the provided PASSPHRASE.')
return keypair
}
/**
* @param {Buffer | ArrayBuffer} json
* @returns {{publicKey: string, privateKey: string}}
*/
function parseStoredKeys (json) {
const storedKeys = JSON.parse(Buffer.from(json).toString())
assert.strictEqual(typeof storedKeys.publicKey, 'string', 'station_id is corrupted: invalid publicKey')
assert.strictEqual(typeof storedKeys.privateKey, 'string', 'station_id is corrupted: invalid privateKey')
return storedKeys
}
/**
* @param {string} keystore
* @param {string} passphrase
* @param {object} args
* @param {import('node:console')} [args.log]
* @returns {Promise<{publicKey: string, privateKey: string}>}
*/
async function generateKeypair (keystore, passphrase, { log }) {
if (!passphrase) {
log.warn(`
*****************************************************************************************
The private key of the identity of your Station instance will be stored in plaintext.
We strongly recommend you to configure PASSPHRASE environment variable to enable
Station to encrypt the private key stored on the filesystem.
*****************************************************************************************
`)
}
const keyPair = /** @type {import('node:crypto').webcrypto.CryptoKeyPair} */ (
/** @type {unknown} */ (
await subtle.generateKey({ name: 'ED25519' }, true, ['sign', 'verify'])
)
)
const publicKey = Buffer.from(await subtle.exportKey('spki', keyPair.publicKey)).toString('hex')
const privateKey = Buffer.from(await subtle.exportKey('pkcs8', keyPair.privateKey)).toString('hex')
log.error('Generated a new Station ID:', publicKey)
await storeKeypair(passphrase, keystore, { publicKey, privateKey })
return { publicKey, privateKey }
}
/**
* @param {string} keystore
* @param {string} passphrase
* @param {{publicKey: string, privateKey: string}} keypair
*/
async function storeKeypair (passphrase, keystore, { publicKey, privateKey }) {
const plaintext = JSON.stringify({ publicKey, privateKey })
const ciphertext = passphrase
? await encrypt(passphrase, Buffer.from(plaintext))
: Buffer.from(plaintext)
await fs.writeFile(keystore, ciphertext)
const keys = { publicKey, privateKey }
return keys
}
//
// The implementation below is loosely based on the following articles
// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/deriveKey#pbkdf2_2
// https://bradyjoslin.com/blog/encryption-webcrypto/
//
/**
* @param {string} passphrase
* @param {Uint8Array} salt
* @returns {Promise<import('node:crypto').webcrypto.CryptoKey>}
*/
async function deriveKeyFromPassphrase (passphrase, salt) {
// Create a password based key (PBKDF2) that will be used to derive
// the AES-GCM key used for encryption / decryption.
const keyMaterial = await subtle.importKey(
'raw',
Buffer.from(passphrase),
'PBKDF2',
/* extractable: */ false,
['deriveKey']
)
// Derive the key used for encryption/decryption
return await subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: 100_000,
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
/* extractable: */ true,
['encrypt', 'decrypt']
)
}
/**
* @param {string} passphrase
* @param {Buffer} plaintext
* @returns {Promise<Buffer>}
*/
export async function encrypt (passphrase, plaintext) {
assert(Buffer.isBuffer(plaintext), 'plaintext must be a Buffer')
const salt = getRandomValues(new Uint8Array(16))
const iv = getRandomValues(new Uint8Array(12))
const key = await deriveKeyFromPassphrase(passphrase, salt)
const ciphertext = await subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext)
const result = Buffer.alloc(salt.byteLength + iv.byteLength + ciphertext.byteLength)
result.set(salt, 0)
result.set(iv, salt.byteLength)
result.set(new Uint8Array(ciphertext), salt.byteLength + iv.byteLength)
return result
}
/**
* @param {string} passphrase
* @param {Buffer} encryptedData
* @returns {Promise<ArrayBuffer>}
*/
export async function decrypt (passphrase, encryptedData) {
assert(Buffer.isBuffer(encryptedData), 'encryptedData must be a Buffer')
const salt = Uint8Array.prototype.slice.call(encryptedData, 0, 16)
const iv = Uint8Array.prototype.slice.call(encryptedData, 16, 16 + 12)
const ciphertext = Uint8Array.prototype.slice.call(encryptedData, 16 + 12)
const key = await deriveKeyFromPassphrase(passphrase, salt)
const plaintext = await subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext)
return plaintext
}