@push.rocks/smartconfig 6.1.0 → 6.2.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/.smartconfig.json +11 -6
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/classes.appdata.js +7 -6
- package/dist_ts/classes.keyvaluestore.d.ts +1 -1
- package/dist_ts/classes.keyvaluestore.js +18 -10
- package/dist_ts/classes.smartconfig.js +4 -3
- package/dist_ts/plugins.d.ts +4 -2
- package/dist_ts/plugins.js +5 -3
- package/dist_ts/sshagent/index.d.ts +1 -0
- package/dist_ts/sshagent/index.js +2 -0
- package/dist_ts/sshagent/smartconfig.sshagentsecret.d.ts +62 -0
- package/dist_ts/sshagent/smartconfig.sshagentsecret.js +453 -0
- package/package.json +31 -18
- package/readme.md +56 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/classes.appdata.ts +8 -7
- package/ts/classes.keyvaluestore.ts +17 -12
- package/ts/classes.smartconfig.ts +5 -7
- package/ts/plugins.ts +6 -2
- package/ts/sshagent/index.ts +1 -0
- package/ts/sshagent/smartconfig.sshagentsecret.ts +602 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCipheriv,
|
|
3
|
+
createDecipheriv,
|
|
4
|
+
createHash,
|
|
5
|
+
createPublicKey,
|
|
6
|
+
hkdfSync,
|
|
7
|
+
randomBytes,
|
|
8
|
+
verify,
|
|
9
|
+
} from 'node:crypto';
|
|
10
|
+
import { promises as fs } from 'node:fs';
|
|
11
|
+
import * as net from 'node:net';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import type { KeyValueStore } from '../classes.keyvaluestore.js';
|
|
16
|
+
|
|
17
|
+
export type TSmartconfigSshKeyType = 'ssh-ed25519';
|
|
18
|
+
export type TSmartconfigSecretFormat = 'ssh-agent-sign-v1';
|
|
19
|
+
export type TSmartconfigSecretBoxAlgorithm = 'AES-256-GCM';
|
|
20
|
+
export type TSmartconfigSecretPayloadEncoding = 'utf8';
|
|
21
|
+
|
|
22
|
+
export interface ISmartconfigSshAgentSecretOptions {
|
|
23
|
+
agentSocket?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ISmartconfigSshAgentSecretEncryptOptions extends ISmartconfigSshAgentSecretOptions {
|
|
27
|
+
authorizedKey?: string;
|
|
28
|
+
authorizedKeyPath?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ISmartconfigSshAgentSecretDecryptOptions extends ISmartconfigSshAgentSecretOptions {}
|
|
32
|
+
|
|
33
|
+
export interface ISmartconfigSshPublicKey {
|
|
34
|
+
type: TSmartconfigSshKeyType;
|
|
35
|
+
keyBlob: Buffer;
|
|
36
|
+
publicKeyBytes: Buffer;
|
|
37
|
+
comment: string;
|
|
38
|
+
fingerprint: string;
|
|
39
|
+
authorizedKey: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ISmartconfigSshAgentIdentity extends ISmartconfigSshPublicKey {}
|
|
43
|
+
|
|
44
|
+
export interface ISmartconfigSshAgentSignature {
|
|
45
|
+
algorithm: string;
|
|
46
|
+
signature: Buffer;
|
|
47
|
+
signatureBlob: Buffer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ISmartconfigSecretKeyHint {
|
|
51
|
+
format: 'openssh-authorized-key-v1';
|
|
52
|
+
authorizedKey: string;
|
|
53
|
+
fingerprint: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ISmartconfigSecretPayload {
|
|
57
|
+
encoding: TSmartconfigSecretPayloadEncoding;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ISmartconfigSecretKdf {
|
|
61
|
+
name: 'HKDF-SHA256';
|
|
62
|
+
salt: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ISmartconfigSecretBox {
|
|
66
|
+
alg: TSmartconfigSecretBoxAlgorithm;
|
|
67
|
+
nonce: string;
|
|
68
|
+
ciphertext: string;
|
|
69
|
+
tag: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ISmartconfigEncryptedValue {
|
|
73
|
+
__smartconfigSecret: TSmartconfigSecretFormat;
|
|
74
|
+
keyHint: ISmartconfigSecretKeyHint;
|
|
75
|
+
payload: ISmartconfigSecretPayload;
|
|
76
|
+
kdf: ISmartconfigSecretKdf;
|
|
77
|
+
box: ISmartconfigSecretBox;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sshAgentFailure = 5;
|
|
81
|
+
const sshAgentRequestIdentities = 11;
|
|
82
|
+
const sshAgentIdentitiesAnswer = 12;
|
|
83
|
+
const sshAgentSignRequest = 13;
|
|
84
|
+
const sshAgentSignResponse = 14;
|
|
85
|
+
const maxAgentResponseBytes = 16 * 1024 * 1024;
|
|
86
|
+
const maxAgentIdentities = 1024;
|
|
87
|
+
const defaultAgentTimeoutMs = 15_000;
|
|
88
|
+
const secretFormat: TSmartconfigSecretFormat = 'ssh-agent-sign-v1';
|
|
89
|
+
const aadEncoding: TSmartconfigSecretPayloadEncoding = 'utf8';
|
|
90
|
+
|
|
91
|
+
class BinaryReader {
|
|
92
|
+
private offset = 0;
|
|
93
|
+
|
|
94
|
+
constructor(private readonly buffer: Buffer) {}
|
|
95
|
+
|
|
96
|
+
public readUInt8(): number {
|
|
97
|
+
this.ensureAvailable(1);
|
|
98
|
+
const value = this.buffer.readUInt8(this.offset);
|
|
99
|
+
this.offset += 1;
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
public readUInt32BE(): number {
|
|
104
|
+
this.ensureAvailable(4);
|
|
105
|
+
const value = this.buffer.readUInt32BE(this.offset);
|
|
106
|
+
this.offset += 4;
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
public readBuffer(lengthArg: number): Buffer {
|
|
111
|
+
this.ensureAvailable(lengthArg);
|
|
112
|
+
const value = this.buffer.subarray(this.offset, this.offset + lengthArg);
|
|
113
|
+
this.offset += lengthArg;
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public readSshStringBuffer(): Buffer {
|
|
118
|
+
return this.readBuffer(this.readUInt32BE());
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
public readSshStringUtf8(): string {
|
|
122
|
+
return this.readSshStringBuffer().toString('utf8');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
public get remainingBytes(): number {
|
|
126
|
+
return this.buffer.length - this.offset;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private ensureAvailable(lengthArg: number): void {
|
|
130
|
+
if (lengthArg < 0 || this.buffer.length - this.offset < lengthArg) {
|
|
131
|
+
throw new Error('Malformed ssh-agent response.');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
class SshAgentClient {
|
|
137
|
+
constructor(private readonly socketPath = process.env.SSH_AUTH_SOCK ?? '') {}
|
|
138
|
+
|
|
139
|
+
public async listIdentities(): Promise<ISmartconfigSshAgentIdentity[]> {
|
|
140
|
+
const response = await this.request(Buffer.from([sshAgentRequestIdentities]));
|
|
141
|
+
const reader = new BinaryReader(response);
|
|
142
|
+
const responseType = reader.readUInt8();
|
|
143
|
+
|
|
144
|
+
if (responseType === sshAgentFailure) {
|
|
145
|
+
throw new Error('ssh-agent refused to list identities.');
|
|
146
|
+
}
|
|
147
|
+
if (responseType !== sshAgentIdentitiesAnswer) {
|
|
148
|
+
throw new Error(`Unexpected ssh-agent response type ${responseType}.`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const count = reader.readUInt32BE();
|
|
152
|
+
if (count > maxAgentIdentities) {
|
|
153
|
+
throw new Error(`Refusing oversized ssh-agent identity list with ${count} identities.`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const identities: ISmartconfigSshAgentIdentity[] = [];
|
|
157
|
+
for (let i = 0; i < count; i++) {
|
|
158
|
+
const keyBlob = reader.readSshStringBuffer();
|
|
159
|
+
const comment = reader.readSshStringUtf8();
|
|
160
|
+
const parsedKeyBlob = tryParseSupportedKeyBlob(keyBlob);
|
|
161
|
+
if (!parsedKeyBlob) continue;
|
|
162
|
+
const fingerprint = fingerprintSha256(keyBlob);
|
|
163
|
+
const authorizedKey = formatAuthorizedKey(parsedKeyBlob.type, keyBlob, comment);
|
|
164
|
+
|
|
165
|
+
identities.push({
|
|
166
|
+
type: parsedKeyBlob.type,
|
|
167
|
+
keyBlob,
|
|
168
|
+
publicKeyBytes: parsedKeyBlob.publicKeyBytes,
|
|
169
|
+
comment,
|
|
170
|
+
fingerprint,
|
|
171
|
+
authorizedKey,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return identities;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
public async sign(keyBlobArg: Buffer, dataArg: Buffer): Promise<ISmartconfigSshAgentSignature> {
|
|
179
|
+
const payload = Buffer.concat([
|
|
180
|
+
Buffer.from([sshAgentSignRequest]),
|
|
181
|
+
sshString(keyBlobArg),
|
|
182
|
+
sshString(dataArg),
|
|
183
|
+
uint32(0),
|
|
184
|
+
]);
|
|
185
|
+
|
|
186
|
+
const response = await this.request(payload);
|
|
187
|
+
const reader = new BinaryReader(response);
|
|
188
|
+
const responseType = reader.readUInt8();
|
|
189
|
+
|
|
190
|
+
if (responseType === sshAgentFailure) {
|
|
191
|
+
throw new Error('ssh-agent refused to sign. The key may be locked, constrained, or missing.');
|
|
192
|
+
}
|
|
193
|
+
if (responseType !== sshAgentSignResponse) {
|
|
194
|
+
throw new Error(`Unexpected ssh-agent response type ${responseType}.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const signatureBlob = reader.readSshStringBuffer();
|
|
198
|
+
const signatureReader = new BinaryReader(signatureBlob);
|
|
199
|
+
const algorithm = signatureReader.readSshStringUtf8();
|
|
200
|
+
const signature = signatureReader.readSshStringBuffer();
|
|
201
|
+
if (signatureReader.remainingBytes !== 0) {
|
|
202
|
+
throw new Error('Malformed ssh-agent signature blob.');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
algorithm,
|
|
207
|
+
signature,
|
|
208
|
+
signatureBlob,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
private async request(payloadArg: Buffer): Promise<Buffer> {
|
|
213
|
+
if (!this.socketPath) {
|
|
214
|
+
throw new Error('SSH_AUTH_SOCK is not set. Start ssh-agent and add the matching SSH key.');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return await new Promise<Buffer>((resolve, reject) => {
|
|
218
|
+
const socket = net.createConnection({ path: this.socketPath });
|
|
219
|
+
const chunks: Buffer[] = [];
|
|
220
|
+
let finished = false;
|
|
221
|
+
let timeoutRef: NodeJS.Timeout | undefined;
|
|
222
|
+
|
|
223
|
+
const cleanup = (): void => {
|
|
224
|
+
if (timeoutRef) clearTimeout(timeoutRef);
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const fail = (errorArg: Error): void => {
|
|
228
|
+
if (finished) return;
|
|
229
|
+
finished = true;
|
|
230
|
+
cleanup();
|
|
231
|
+
socket.destroy();
|
|
232
|
+
reject(errorArg);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
timeoutRef = setTimeout(() => {
|
|
236
|
+
fail(new Error(`ssh-agent request timed out after ${defaultAgentTimeoutMs}ms.`));
|
|
237
|
+
}, defaultAgentTimeoutMs);
|
|
238
|
+
timeoutRef.unref?.();
|
|
239
|
+
|
|
240
|
+
socket.on('connect', () => {
|
|
241
|
+
socket.write(Buffer.concat([uint32(payloadArg.length), payloadArg]));
|
|
242
|
+
});
|
|
243
|
+
socket.on('error', fail);
|
|
244
|
+
socket.on('close', () => {
|
|
245
|
+
if (!finished) {
|
|
246
|
+
fail(new Error('ssh-agent closed the connection before sending a complete response.'));
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
socket.on('data', (chunkArg) => {
|
|
250
|
+
if (finished) return;
|
|
251
|
+
chunks.push(Buffer.from(chunkArg));
|
|
252
|
+
const buffer = Buffer.concat(chunks);
|
|
253
|
+
if (buffer.length < 4) return;
|
|
254
|
+
|
|
255
|
+
const responseLength = buffer.readUInt32BE(0);
|
|
256
|
+
if (responseLength > maxAgentResponseBytes) {
|
|
257
|
+
fail(new Error('Refusing oversized ssh-agent response.'));
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (buffer.length < 4 + responseLength) return;
|
|
261
|
+
|
|
262
|
+
finished = true;
|
|
263
|
+
cleanup();
|
|
264
|
+
socket.destroy();
|
|
265
|
+
resolve(buffer.subarray(4, 4 + responseLength));
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export class SmartconfigSshAgentSecrets {
|
|
272
|
+
public static async encryptString(
|
|
273
|
+
valueArg: string,
|
|
274
|
+
optionsArg: ISmartconfigSshAgentSecretEncryptOptions = {},
|
|
275
|
+
): Promise<ISmartconfigEncryptedValue> {
|
|
276
|
+
const publicKey = await resolvePublicKey(optionsArg);
|
|
277
|
+
const agent = new SshAgentClient(optionsArg.agentSocket);
|
|
278
|
+
const identity = await findIdentity(agent, publicKey);
|
|
279
|
+
const salt = randomBytes(32);
|
|
280
|
+
const challenge = makeChallenge(publicKey, salt);
|
|
281
|
+
const signature = await agent.sign(identity.keyBlob, challenge);
|
|
282
|
+
|
|
283
|
+
assertEd25519Signature(signature);
|
|
284
|
+
assertSignatureMatchesPublicKey(publicKey, challenge, signature.signature);
|
|
285
|
+
|
|
286
|
+
const kek = deriveKek(signature.signature, salt, publicKey);
|
|
287
|
+
const aad = makeAad(publicKey, salt);
|
|
288
|
+
const box = encryptAes256Gcm(kek, Buffer.from(valueArg, 'utf8'), aad);
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
__smartconfigSecret: secretFormat,
|
|
292
|
+
keyHint: {
|
|
293
|
+
format: 'openssh-authorized-key-v1',
|
|
294
|
+
authorizedKey: publicKey.authorizedKey,
|
|
295
|
+
fingerprint: publicKey.fingerprint,
|
|
296
|
+
},
|
|
297
|
+
payload: {
|
|
298
|
+
encoding: aadEncoding,
|
|
299
|
+
},
|
|
300
|
+
kdf: {
|
|
301
|
+
name: 'HKDF-SHA256',
|
|
302
|
+
salt: toBase64(salt),
|
|
303
|
+
},
|
|
304
|
+
box,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
public static async decryptString(
|
|
309
|
+
encryptedValueArg: ISmartconfigEncryptedValue,
|
|
310
|
+
optionsArg: ISmartconfigSshAgentSecretDecryptOptions = {},
|
|
311
|
+
): Promise<string> {
|
|
312
|
+
const buffer = await this.decryptBuffer(encryptedValueArg, optionsArg);
|
|
313
|
+
return buffer.toString('utf8');
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
public static async decryptBuffer(
|
|
317
|
+
encryptedValueArg: ISmartconfigEncryptedValue,
|
|
318
|
+
optionsArg: ISmartconfigSshAgentSecretDecryptOptions = {},
|
|
319
|
+
): Promise<Buffer> {
|
|
320
|
+
const normalized = normalizeEncryptedValue(encryptedValueArg);
|
|
321
|
+
const publicKey = parseOpenSshPublicKeyLine(normalized.keyHint.authorizedKey);
|
|
322
|
+
if (publicKey.fingerprint !== normalized.keyHint.fingerprint) {
|
|
323
|
+
throw new Error('Encrypted value public key does not match its fingerprint.');
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const salt = fromBase64(normalized.kdf.salt, 'kdf.salt', 32);
|
|
327
|
+
const agent = new SshAgentClient(optionsArg.agentSocket);
|
|
328
|
+
const identity = await findIdentity(agent, publicKey);
|
|
329
|
+
const challenge = makeChallenge(publicKey, salt);
|
|
330
|
+
const signature = await agent.sign(identity.keyBlob, challenge);
|
|
331
|
+
|
|
332
|
+
assertEd25519Signature(signature);
|
|
333
|
+
assertSignatureMatchesPublicKey(publicKey, challenge, signature.signature);
|
|
334
|
+
|
|
335
|
+
const kek = deriveKek(signature.signature, salt, publicKey);
|
|
336
|
+
return decryptAes256Gcm(kek, normalized.box, makeAad(publicKey, salt));
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
public static isEncryptedValue(valueArg: unknown): valueArg is ISmartconfigEncryptedValue {
|
|
340
|
+
return Boolean(
|
|
341
|
+
valueArg
|
|
342
|
+
&& typeof valueArg === 'object'
|
|
343
|
+
&& (valueArg as Partial<ISmartconfigEncryptedValue>).__smartconfigSecret === secretFormat,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
public static async writeKey(
|
|
348
|
+
kvStoreArg: Pick<KeyValueStore<any>, 'writeKey'>,
|
|
349
|
+
keyArg: string,
|
|
350
|
+
valueArg: string,
|
|
351
|
+
optionsArg: ISmartconfigSshAgentSecretEncryptOptions = {},
|
|
352
|
+
): Promise<ISmartconfigEncryptedValue> {
|
|
353
|
+
const encryptedValue = await this.encryptString(valueArg, optionsArg);
|
|
354
|
+
await kvStoreArg.writeKey(keyArg, encryptedValue);
|
|
355
|
+
return encryptedValue;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
public static async readKey(
|
|
359
|
+
kvStoreArg: Pick<KeyValueStore<any>, 'readKey'>,
|
|
360
|
+
keyArg: string,
|
|
361
|
+
optionsArg: ISmartconfigSshAgentSecretDecryptOptions = {},
|
|
362
|
+
): Promise<string> {
|
|
363
|
+
const value = await kvStoreArg.readKey(keyArg);
|
|
364
|
+
if (!this.isEncryptedValue(value)) {
|
|
365
|
+
throw new Error(`Key "${keyArg}" does not contain a smartconfig ssh-agent encrypted value.`);
|
|
366
|
+
}
|
|
367
|
+
return await this.decryptString(value, optionsArg);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function resolvePublicKey(optionsArg: ISmartconfigSshAgentSecretEncryptOptions): Promise<ISmartconfigSshPublicKey> {
|
|
372
|
+
if (optionsArg.authorizedKey) {
|
|
373
|
+
return parseOpenSshPublicKeyLine(optionsArg.authorizedKey);
|
|
374
|
+
}
|
|
375
|
+
const authorizedKeyPath = optionsArg.authorizedKeyPath ?? '~/.ssh/id_ed25519.pub';
|
|
376
|
+
const content = await fs.readFile(expandHome(authorizedKeyPath), 'utf8');
|
|
377
|
+
return parseOpenSshPublicKeyLine(content);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function findIdentity(agentArg: SshAgentClient, publicKeyArg: ISmartconfigSshPublicKey): Promise<ISmartconfigSshAgentIdentity> {
|
|
381
|
+
const identities = await agentArg.listIdentities();
|
|
382
|
+
const identity = identities.find((candidate) => candidate.fingerprint === publicKeyArg.fingerprint && candidate.keyBlob.equals(publicKeyArg.keyBlob));
|
|
383
|
+
if (!identity) {
|
|
384
|
+
throw new Error(`Required SSH key ${publicKeyArg.fingerprint} is not loaded in ssh-agent.`);
|
|
385
|
+
}
|
|
386
|
+
return identity;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function normalizeEncryptedValue(valueArg: unknown): ISmartconfigEncryptedValue {
|
|
390
|
+
if (!SmartconfigSshAgentSecrets.isEncryptedValue(valueArg)) {
|
|
391
|
+
throw new Error('Value is not a smartconfig ssh-agent encrypted value.');
|
|
392
|
+
}
|
|
393
|
+
if (valueArg.keyHint?.format !== 'openssh-authorized-key-v1') {
|
|
394
|
+
throw new Error('Encrypted value uses an unsupported key hint format.');
|
|
395
|
+
}
|
|
396
|
+
if (typeof valueArg.keyHint.authorizedKey !== 'string' || !valueArg.keyHint.authorizedKey) {
|
|
397
|
+
throw new Error('Encrypted value is missing an authorized key hint.');
|
|
398
|
+
}
|
|
399
|
+
if (typeof valueArg.keyHint.fingerprint !== 'string' || !valueArg.keyHint.fingerprint.startsWith('SHA256:')) {
|
|
400
|
+
throw new Error('Encrypted value is missing a supported SSH key fingerprint.');
|
|
401
|
+
}
|
|
402
|
+
if (valueArg.payload?.encoding !== aadEncoding) {
|
|
403
|
+
throw new Error('Encrypted value uses an unsupported payload encoding.');
|
|
404
|
+
}
|
|
405
|
+
if (valueArg.kdf?.name !== 'HKDF-SHA256') {
|
|
406
|
+
throw new Error('Encrypted value uses an unsupported KDF.');
|
|
407
|
+
}
|
|
408
|
+
if (valueArg.box?.alg !== 'AES-256-GCM') {
|
|
409
|
+
throw new Error('Encrypted value uses an unsupported encryption algorithm.');
|
|
410
|
+
}
|
|
411
|
+
return valueArg;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function parseOpenSshPublicKeyLine(lineOrFileArg: string): ISmartconfigSshPublicKey {
|
|
415
|
+
const line = lineOrFileArg
|
|
416
|
+
.split(/\r?\n/)
|
|
417
|
+
.map((candidate) => candidate.trim())
|
|
418
|
+
.find((candidate) => candidate && !candidate.startsWith('#'));
|
|
419
|
+
|
|
420
|
+
if (!line) {
|
|
421
|
+
throw new Error('No OpenSSH authorized key line found.');
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const tokens = line.split(/\s+/);
|
|
425
|
+
const keyIndex = tokens.findIndex((token) => token === 'ssh-ed25519');
|
|
426
|
+
if (keyIndex === -1 || !tokens[keyIndex + 1]) {
|
|
427
|
+
throw new Error('Only ssh-ed25519 authorized key hints are supported.');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const keyBlob = fromBase64(tokens[keyIndex + 1], 'authorizedKey.keyBlob');
|
|
431
|
+
const parsedKeyBlob = parseSupportedKeyBlob(keyBlob);
|
|
432
|
+
if (parsedKeyBlob.type !== tokens[keyIndex]) {
|
|
433
|
+
throw new Error(`SSH public key type mismatch: line says ${tokens[keyIndex]}, blob says ${parsedKeyBlob.type}.`);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const comment = tokens.slice(keyIndex + 2).join(' ');
|
|
437
|
+
const fingerprint = fingerprintSha256(keyBlob);
|
|
438
|
+
return {
|
|
439
|
+
type: parsedKeyBlob.type,
|
|
440
|
+
keyBlob,
|
|
441
|
+
publicKeyBytes: parsedKeyBlob.publicKeyBytes,
|
|
442
|
+
comment,
|
|
443
|
+
fingerprint,
|
|
444
|
+
authorizedKey: formatAuthorizedKey(parsedKeyBlob.type, keyBlob, comment),
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function parseSupportedKeyBlob(keyBlobArg: Buffer): { type: TSmartconfigSshKeyType; publicKeyBytes: Buffer } {
|
|
449
|
+
const reader = new BinaryReader(keyBlobArg);
|
|
450
|
+
const type = reader.readSshStringUtf8();
|
|
451
|
+
if (type !== 'ssh-ed25519') {
|
|
452
|
+
throw new Error(`Unsupported SSH key type: ${type}.`);
|
|
453
|
+
}
|
|
454
|
+
const publicKeyBytes = reader.readSshStringBuffer();
|
|
455
|
+
if (publicKeyBytes.length !== 32) {
|
|
456
|
+
throw new Error(`Expected 32-byte Ed25519 public key, got ${publicKeyBytes.length} bytes.`);
|
|
457
|
+
}
|
|
458
|
+
if (reader.remainingBytes !== 0) {
|
|
459
|
+
throw new Error('Malformed ssh-ed25519 public key blob.');
|
|
460
|
+
}
|
|
461
|
+
return { type, publicKeyBytes };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function tryParseSupportedKeyBlob(keyBlobArg: Buffer): { type: TSmartconfigSshKeyType; publicKeyBytes: Buffer } | undefined {
|
|
465
|
+
try {
|
|
466
|
+
return parseSupportedKeyBlob(keyBlobArg);
|
|
467
|
+
} catch (error) {
|
|
468
|
+
if ((error as Error).message.startsWith('Unsupported SSH key type:')) {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function assertEd25519Signature(signatureArg: ISmartconfigSshAgentSignature): void {
|
|
476
|
+
if (signatureArg.algorithm !== 'ssh-ed25519') {
|
|
477
|
+
throw new Error(`Expected ssh-ed25519 signature, got ${signatureArg.algorithm}.`);
|
|
478
|
+
}
|
|
479
|
+
if (signatureArg.signature.length !== 64) {
|
|
480
|
+
throw new Error(`Expected 64-byte Ed25519 signature, got ${signatureArg.signature.length} bytes.`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function assertSignatureMatchesPublicKey(
|
|
485
|
+
publicKeyArg: ISmartconfigSshPublicKey,
|
|
486
|
+
challengeArg: Buffer,
|
|
487
|
+
signatureArg: Buffer,
|
|
488
|
+
): void {
|
|
489
|
+
const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex');
|
|
490
|
+
const keyObject = createPublicKey({
|
|
491
|
+
key: Buffer.concat([spkiPrefix, publicKeyArg.publicKeyBytes]),
|
|
492
|
+
format: 'der',
|
|
493
|
+
type: 'spki',
|
|
494
|
+
});
|
|
495
|
+
const valid = verify(null, challengeArg, keyObject, signatureArg);
|
|
496
|
+
if (!valid) {
|
|
497
|
+
throw new Error('ssh-agent signature does not match the encrypted value key hint.');
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function makeChallenge(publicKeyArg: ISmartconfigSshPublicKey, saltArg: Buffer): Buffer {
|
|
502
|
+
return Buffer.concat([
|
|
503
|
+
Buffer.from('smartconfig:ssh-agent-sign-v1:kek\n', 'utf8'),
|
|
504
|
+
sshString(publicKeyArg.fingerprint),
|
|
505
|
+
sshString(publicKeyArg.authorizedKey),
|
|
506
|
+
sshString(saltArg),
|
|
507
|
+
]);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function deriveKek(signatureArg: Buffer, saltArg: Buffer, publicKeyArg: ISmartconfigSshPublicKey): Buffer {
|
|
511
|
+
const info = Buffer.concat([
|
|
512
|
+
Buffer.from('smartconfig:ssh-agent-sign-v1:hkdf-info\n', 'utf8'),
|
|
513
|
+
sshString(publicKeyArg.fingerprint),
|
|
514
|
+
sshString(publicKeyArg.authorizedKey),
|
|
515
|
+
]);
|
|
516
|
+
return Buffer.from(hkdfSync('sha256', signatureArg, saltArg, info, 32));
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function makeAad(publicKeyArg: ISmartconfigSshPublicKey, saltArg: Buffer): Buffer {
|
|
520
|
+
return Buffer.from(JSON.stringify({
|
|
521
|
+
format: secretFormat,
|
|
522
|
+
keyHint: {
|
|
523
|
+
format: 'openssh-authorized-key-v1',
|
|
524
|
+
authorizedKey: publicKeyArg.authorizedKey,
|
|
525
|
+
fingerprint: publicKeyArg.fingerprint,
|
|
526
|
+
},
|
|
527
|
+
payload: {
|
|
528
|
+
encoding: aadEncoding,
|
|
529
|
+
},
|
|
530
|
+
kdf: {
|
|
531
|
+
name: 'HKDF-SHA256',
|
|
532
|
+
salt: toBase64(saltArg),
|
|
533
|
+
},
|
|
534
|
+
}), 'utf8');
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function encryptAes256Gcm(keyArg: Buffer, plaintextArg: Buffer, aadArg: Buffer): ISmartconfigSecretBox {
|
|
538
|
+
const nonce = randomBytes(12);
|
|
539
|
+
const cipher = createCipheriv('aes-256-gcm', keyArg, nonce);
|
|
540
|
+
cipher.setAAD(aadArg);
|
|
541
|
+
const ciphertext = Buffer.concat([cipher.update(plaintextArg), cipher.final()]);
|
|
542
|
+
const tag = cipher.getAuthTag();
|
|
543
|
+
return {
|
|
544
|
+
alg: 'AES-256-GCM',
|
|
545
|
+
nonce: toBase64(nonce),
|
|
546
|
+
ciphertext: toBase64(ciphertext),
|
|
547
|
+
tag: toBase64(tag),
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function decryptAes256Gcm(keyArg: Buffer, boxArg: ISmartconfigSecretBox, aadArg: Buffer): Buffer {
|
|
552
|
+
if (boxArg.alg !== 'AES-256-GCM') {
|
|
553
|
+
throw new Error(`Unsupported encryption algorithm: ${boxArg.alg}.`);
|
|
554
|
+
}
|
|
555
|
+
const nonce = fromBase64(boxArg.nonce, 'box.nonce', 12);
|
|
556
|
+
const ciphertext = fromBase64(boxArg.ciphertext, 'box.ciphertext');
|
|
557
|
+
const tag = fromBase64(boxArg.tag, 'box.tag', 16);
|
|
558
|
+
const decipher = createDecipheriv('aes-256-gcm', keyArg, nonce);
|
|
559
|
+
decipher.setAAD(aadArg);
|
|
560
|
+
decipher.setAuthTag(tag);
|
|
561
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function formatAuthorizedKey(typeArg: TSmartconfigSshKeyType, keyBlobArg: Buffer, commentArg: string): string {
|
|
565
|
+
return `${typeArg} ${keyBlobArg.toString('base64')}${commentArg ? ` ${commentArg}` : ''}`;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function fingerprintSha256(keyBlobArg: Buffer): string {
|
|
569
|
+
return `SHA256:${createHash('sha256').update(keyBlobArg).digest('base64').replace(/=+$/g, '')}`;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function sshString(valueArg: Buffer | string): Buffer {
|
|
573
|
+
const buffer = Buffer.isBuffer(valueArg) ? valueArg : Buffer.from(valueArg, 'utf8');
|
|
574
|
+
return Buffer.concat([uint32(buffer.length), buffer]);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function uint32(valueArg: number): Buffer {
|
|
578
|
+
const buffer = Buffer.alloc(4);
|
|
579
|
+
buffer.writeUInt32BE(valueArg, 0);
|
|
580
|
+
return buffer;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function toBase64(valueArg: Buffer): string {
|
|
584
|
+
return valueArg.toString('base64');
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function fromBase64(valueArg: string, fieldNameArg: string, expectedLengthArg?: number): Buffer {
|
|
588
|
+
if (typeof valueArg !== 'string' || !valueArg || valueArg.length % 4 === 1 || !/^[A-Za-z0-9+/]+={0,2}$/.test(valueArg)) {
|
|
589
|
+
throw new Error(`${fieldNameArg} must be a base64 string.`);
|
|
590
|
+
}
|
|
591
|
+
const buffer = Buffer.from(valueArg, 'base64');
|
|
592
|
+
if (expectedLengthArg !== undefined && buffer.length !== expectedLengthArg) {
|
|
593
|
+
throw new Error(`${fieldNameArg} must decode to ${expectedLengthArg} bytes.`);
|
|
594
|
+
}
|
|
595
|
+
return buffer;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function expandHome(pathArg: string): string {
|
|
599
|
+
if (pathArg === '~') return homedir();
|
|
600
|
+
if (pathArg.startsWith('~/')) return join(homedir(), pathArg.slice(2));
|
|
601
|
+
return pathArg;
|
|
602
|
+
}
|