vvauth 2.2.0 → 2.4.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
@@ -63,10 +63,13 @@ vauth env --ir://json
63
63
 
64
64
  # Token cache
65
65
 
66
- Tokens are cached in `~/.vauth/token.json`, isolated by Vault and
66
+ Tokens are cached in `~/.vauth/tokens/<session>.creds`, isolated by Vault and
67
67
  authentication. Tokens are reused, renewed within their lease’s final third, or
68
68
  replaced when expired. Existing `VAULT_TOKEN` takes precedence over cached tokens.
69
69
 
70
+ Each session file is armored using `ssh-agent-crypt` and your SSH agent’s first
71
+ key. Without an SSH agent, disk caching is disabled.
72
+
70
73
  Set `token_cache: false` in `.vauthrc` to disable disk cache reads and writes.
71
74
 
72
75
  ## Force reauthentication
package/index.js CHANGED
@@ -6,9 +6,9 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const url = require('url');
8
8
  const {createHash} = require('crypto');
9
+ const {encrypt, decrypt} = require('ssh-agent-crypt');
9
10
  const {spawn, execFileSync} = require('child_process');
10
11
  const passthru = require('nyks/child_process/passthru');
11
- const wait = require('nyks/child_process/wait');
12
12
  const boolPrompt = require('cnyks/prompt/bool');
13
13
 
14
14
  const {parse} = require('yaml');
@@ -89,6 +89,9 @@ class vvauth {
89
89
  }
90
90
 
91
91
 
92
+ if(!process.env.SSH_AUTH_SOCK)
93
+ this.rc.token_cache = false;
94
+
92
95
  this.VAULT_ADDR = this.rc.vault_addr || process.env.VAULT_ADDR; //might be null
93
96
  this.VAULT_TOKEN = process.env.VAULT_TOKEN;
94
97
 
@@ -112,40 +115,38 @@ class vvauth {
112
115
  }
113
116
 
114
117
  _token_cache_file() {
115
- return path.join(os.homedir(), '.vauth', 'token.json');
118
+ return path.join(os.homedir(), '.vauth', 'tokens', this._token_cache_key() + '.creds');
116
119
  }
117
120
 
118
- _read_token_cache() {
121
+ async _read_token_cache() {
119
122
  if(this.rc.token_cache === false)
120
- return {version : 1, tokens : {}};
123
+ return;
121
124
  try {
122
- const cache = JSON.parse(fs.readFileSync(this._token_cache_file(), 'utf8'));
123
- if(cache.version === 1 && cache.tokens && typeof cache.tokens === 'object' && !Array.isArray(cache.tokens))
124
- return cache;
125
+ const armor = fs.readFileSync(this._token_cache_file(), 'utf8');
126
+ return JSON.parse(await decrypt(armor));
125
127
  } catch(err) {}
126
- return {version : 1, tokens : {}};
127
128
  }
128
129
 
129
- _save_cached_token(token, metadata) {
130
+ async _save_cached_token(token, metadata) {
130
131
  if(this.rc.token_cache === false)
131
132
  return;
132
133
  const ttl = metadata.ttl === undefined ? metadata.lease_duration : metadata.ttl;
133
134
  const file = this._token_cache_file();
134
135
  const temporary = file + '.next';
135
136
  try {
136
- const cache = this._read_token_cache();
137
- const key = this._token_cache_key();
138
137
  const {lease_duration} = metadata;
139
- cache.tokens[key] = {
138
+ const cache = {
140
139
  token, lease_duration, renewable : metadata.renewable === true,
141
140
  expires_at : ttl === 0 ? null : Date.now() + ttl * 1000,
142
141
  };
143
142
  fs.mkdirSync(path.dirname(file), {recursive : true, mode : 0o700});
144
- fs.writeFileSync(temporary, JSON.stringify(cache, null, 2) + '\n', {mode : 0o600});
143
+ const identity = process.env.VAUTH_USER_IDENTITY || process.env.VAUTH_USER_MAIL;
144
+ const armor = await encrypt(JSON.stringify(cache), identity);
145
+ fs.writeFileSync(temporary, armor, {mode : 0o600});
145
146
  fs.renameSync(temporary, file);
146
147
  } catch(err) {
147
148
  // Cache failures must not prevent authentication or expose credentials.
148
- logger.error('Could not write token cache (%s)', err.code || 'invalid cache');
149
+ console.error('Could not encrypt/write token cache; token not persisted');
149
150
  } finally {
150
151
  try { fs.unlinkSync(temporary); } catch(err) {}
151
152
  }
@@ -158,11 +159,16 @@ class vvauth {
158
159
  }
159
160
 
160
161
  async connect(renew = false) {
162
+ const {ssh_auth, jwt_auth} = this.rc;
163
+ if(!ssh_auth && !jwt_auth) {
164
+ this.VAULT_TOKEN = undefined;
165
+ return;
166
+ }
161
167
  if(this._connected && !renew)
162
168
  return this.VAULT_TOKEN;
163
169
  this._connected = false;
164
170
 
165
- const cached = this._read_token_cache().tokens[this._token_cache_key()];
171
+ const cached = await this._read_token_cache();
166
172
  let token = this.VAULT_TOKEN || (cached && cached.token);
167
173
  const had_token = !!token;
168
174
  let metadata, operation = 'reused';
@@ -197,7 +203,6 @@ class vvauth {
197
203
  }
198
204
 
199
205
  if(!token) {
200
- const {ssh_auth, jwt_auth} = this.rc;
201
206
  const agent_path = process.env.SSH_AUTH_SOCK || (process.platform === 'win32' ? 'pageant' : null);
202
207
  if(ssh_auth && agent_path) {
203
208
  metadata = await this._login_vault_ssh({...ssh_auth}, agent_path);
@@ -213,7 +218,7 @@ class vvauth {
213
218
 
214
219
  if(token) {
215
220
  if(operation !== 'reused' || !cached || cached.token !== token)
216
- this._save_cached_token(token, metadata);
221
+ await this._save_cached_token(token, metadata);
217
222
  this._token_status(operation, metadata);
218
223
  }
219
224
  this.VAULT_TOKEN = token;
@@ -301,16 +306,7 @@ class vvauth {
301
306
  let {'ssh-agent-crypt' : agent } = this.rc;
302
307
  if(agent) {
303
308
  const {path, identity} = agent;
304
- let child = spawn('ssh-agent-crypt', ["-decrypt", identity]);
305
-
306
- child.stdin.end(fs.readFileSync(path));
307
- child.stderr.pipe(process.stderr);
308
-
309
- const [exit, body] = await Promise.all([wait(child, false), drain(child.stdout)]);
310
- if(exit !== 0) {
311
- console.error("Could not expand armored %s using %s", path, identity);
312
- process.exit();
313
- }
309
+ const body = await decrypt(fs.readFileSync(path), identity);
314
310
  const result = JSON.parse(body);
315
311
  secrets = {...secrets, ...result};
316
312
  }
@@ -445,7 +441,7 @@ class vvauth {
445
441
  let response = JSON.parse(await drain(res)).data;
446
442
  if(!Number.isFinite(response.ttl) || response.ttl < 0)
447
443
  throw new Error('Invalid Vault token TTL');
448
- const cached = this._read_token_cache().tokens[this._token_cache_key()];
444
+ const cached = await this._read_token_cache();
449
445
  if(cached && cached.token === token)
450
446
  response.lease_duration = cached.lease_duration;
451
447
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vvauth",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Vault Auth helper",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -18,7 +18,7 @@
18
18
  "mout": "^1.2.4",
19
19
  "nyks": "^6.15.0",
20
20
  "semver": "^7.5.4",
21
- "ssh-agent-crypt": "^1.0.1",
21
+ "ssh-agent-crypt": "^1.2.0",
22
22
  "ssh2": "^1.16.0",
23
23
  "yaml": "^2.6.1"
24
24
  },
@@ -1,21 +1,58 @@
1
1
  'use strict';
2
2
 
3
3
  const assert = require('node:assert/strict');
4
- const {test} = require('node:test');
4
+ const {test, before, after} = require('node:test');
5
5
  const fs = require('fs');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
8
  const http = require('http');
9
- const {execFile, spawn} = require('child_process');
9
+ const {execFile, spawn, spawnSync} = require('child_process');
10
10
  const {promisify, format} = require('util');
11
11
  const exec = promisify(execFile);
12
12
  const Vauth = require('../index');
13
13
 
14
+ const {encrypt, decrypt} = require('ssh-agent-crypt');
15
+ const readCache = async file => JSON.parse(await decrypt(fs.readFileSync(file, 'utf8')));
16
+ const writeCache = async (file, cache) => fs.writeFileSync(file, await encrypt(JSON.stringify(cache)));
17
+ let agentDirectory, agentPid;
18
+ const previousSocket = process.env.SSH_AUTH_SOCK;
19
+ const previousIdentity = process.env.VAUTH_USER_IDENTITY;
20
+ const previousMail = process.env.VAUTH_USER_MAIL;
21
+
22
+ before(() => {
23
+ delete process.env.VAUTH_USER_IDENTITY;
24
+ delete process.env.VAUTH_USER_MAIL;
25
+ agentDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'vvauth-agent-'));
26
+ const key = path.join(agentDirectory, 'key');
27
+ assert.equal(spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-f', key]).status, 0);
28
+ const agent = spawnSync('ssh-agent', ['-s'], {encoding : 'utf8'});
29
+ assert.equal(agent.status, 0);
30
+ process.env.SSH_AUTH_SOCK = agent.stdout.match(/SSH_AUTH_SOCK=([^;]+)/)[1];
31
+ agentPid = agent.stdout.match(/SSH_AGENT_PID=(\d+)/)[1];
32
+ assert.equal(spawnSync('ssh-add', [key]).status, 0);
33
+ });
34
+
35
+ after(() => {
36
+ for(const [name, value] of [['VAUTH_USER_IDENTITY', previousIdentity], ['VAUTH_USER_MAIL', previousMail]]) {
37
+ if(value === undefined)
38
+ delete process.env[name];
39
+ else
40
+ process.env[name] = value;
41
+ }
42
+ if(agentPid)
43
+ process.kill(Number(agentPid));
44
+ if(previousSocket === undefined)
45
+ delete process.env.SSH_AUTH_SOCK;
46
+ else
47
+ process.env.SSH_AUTH_SOCK = previousSocket;
48
+ fs.rmSync(agentDirectory, {recursive : true, force : true});
49
+ });
50
+
14
51
  const HOUR = 60 * 60;
15
52
 
16
53
  async function fixture(t, options = {}) {
17
54
  const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'vvauth-test-'));
18
- const file = path.join(directory, '.vauth', 'token.json');
55
+ let file;
19
56
  const requests = [];
20
57
  const server = http.createServer(async (req, res) => {
21
58
  let body = '';
@@ -48,20 +85,22 @@ async function fixture(t, options = {}) {
48
85
  const instance = Object.create(Vauth.prototype);
49
86
  instance.rc = {jwt_auth : {path : 'jwt', jwt : 'test-jwt', role : 'test-role'}};
50
87
  instance.VAULT_ADDR = `http://127.0.0.1:${server.address().port}`;
51
- instance._token_cache_file = () => file;
88
+ instance._token_cache_file = () => path.join(directory, '.vauth', 'tokens', instance._token_cache_key() + '.creds');
52
89
  return instance;
53
90
  };
54
- const remaining = (instance, ttl) => {
55
- const cache = JSON.parse(fs.readFileSync(file, 'utf8'));
56
- cache.tokens[instance._token_cache_key()].expires_at = ttl === 0 ? null : Date.now() + ttl * 1000;
57
- fs.writeFileSync(file, JSON.stringify(cache));
91
+ file = client()._token_cache_file();
92
+ const remaining = async (instance, ttl) => {
93
+ const file = instance._token_cache_file();
94
+ const cache = (await readCache(file));
95
+ cache.expires_at = ttl === 0 ? null : Date.now() + ttl * 1000;
96
+ await writeCache(file, cache);
58
97
  };
59
- const seed = (instance, lease = 12 * HOUR) => {
60
- instance._save_cached_token('cached-token', {lease_duration : lease, renewable : options.renewable !== false});
98
+ const seed = async (instance, lease = 12 * HOUR) => {
99
+ await instance._save_cached_token('cached-token', {lease_duration : lease, renewable : options.renewable !== false});
61
100
  if(options.ttl !== undefined)
62
- remaining(instance, options.ttl);
101
+ await remaining(instance, options.ttl);
63
102
  };
64
- const read = instance => JSON.parse(fs.readFileSync(file, 'utf8')).tokens[instance._token_cache_key()];
103
+ const read = async instance => readCache(instance._token_cache_file());
65
104
  return {client, seed, read, requests, file, remaining};
66
105
  }
67
106
 
@@ -69,31 +108,31 @@ test('login persists a token; a new instance reuses it without logging in', asyn
69
108
  const {client, read, requests, file} = await fixture(t);
70
109
  const first = client();
71
110
  assert.equal(await first.connect(), 'fresh-token');
72
- assert.equal(read(first).token, 'fresh-token');
73
- assert.ok(read(first).expires_at > Date.now() + 23 * HOUR * 1000);
111
+ assert.equal((await read(first)).token, 'fresh-token');
112
+ assert.ok((await read(first)).expires_at > Date.now() + 23 * HOUR * 1000);
74
113
  assert.equal(fs.statSync(file).mode & 0o777, 0o600);
75
114
  assert.equal(fs.statSync(path.dirname(file)).mode & 0o777, 0o700);
76
115
  const second = client();
77
116
  assert.equal(await second.connect(), 'fresh-token');
78
117
  assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
79
- assert.deepEqual(fs.readdirSync(path.dirname(file)), ['token.json']);
118
+ assert.deepEqual(fs.readdirSync(path.dirname(file)), [path.basename(file)]);
80
119
  });
81
120
 
82
121
  test('a token in the last third of its lease is renewed and saved', async t => {
83
122
  const {client, seed, read, requests} = await fixture(t, {ttl : HOUR});
84
123
  const instance = client();
85
- seed(instance, 12 * HOUR);
124
+ await seed(instance, 12 * HOUR);
86
125
  assert.equal(await instance.connect(), 'cached-token');
87
126
  assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
88
127
  assert.equal(requests[0].method, 'POST');
89
128
  assert.equal(requests[0].token, 'cached-token');
90
- assert.ok(read(instance).expires_at > Date.now() + 23 * HOUR * 1000);
129
+ assert.ok((await read(instance)).expires_at > Date.now() + 23 * HOUR * 1000);
91
130
  });
92
131
 
93
132
  test('more than one third remaining does not trigger renewal', async t => {
94
133
  const {client, seed, requests} = await fixture(t, {ttl : 4 * HOUR + 1});
95
134
  const instance = client();
96
- seed(instance);
135
+ await seed(instance);
97
136
  assert.equal(await instance.connect(), 'cached-token');
98
137
  assert.equal(requests.length, 0);
99
138
  });
@@ -101,19 +140,19 @@ test('more than one third remaining does not trigger renewal', async t => {
101
140
  test('expired cached tokens are replaced with a new persisted login', async t => {
102
141
  const {client, seed, read, requests, file} = await fixture(t);
103
142
  const instance = client();
104
- seed(instance);
105
- const cache = JSON.parse(fs.readFileSync(file, 'utf8'));
106
- cache.tokens[instance._token_cache_key()].expires_at = Date.now() - 1000;
107
- fs.writeFileSync(file, JSON.stringify(cache));
143
+ await seed(instance);
144
+ const cache = (await readCache(file));
145
+ cache.expires_at = Date.now() - 1000;
146
+ await writeCache(file, cache);
108
147
  assert.equal(await instance.connect(), 'fresh-token');
109
- assert.equal(read(instance).token, 'fresh-token');
148
+ assert.equal((await read(instance)).token, 'fresh-token');
110
149
  assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
111
150
  });
112
151
 
113
152
  test('cached tokens are reused without checking revocation', async t => {
114
153
  const {client, seed, requests} = await fixture(t, {lookup_status : 403});
115
154
  const instance = client();
116
- seed(instance);
155
+ await seed(instance);
117
156
  assert.equal(await instance.connect(), 'cached-token');
118
157
  assert.deepEqual(requests, []);
119
158
  await assert.rejects(instance._lookup_token(instance.VAULT_TOKEN));
@@ -122,7 +161,7 @@ test('cached tokens are reused without checking revocation', async t => {
122
161
  test('nonrenewable tokens nearing expiry trigger reauthentication', async t => {
123
162
  const {client, seed, requests} = await fixture(t, {ttl : HOUR, renewable : false});
124
163
  const instance = client();
125
- seed(instance);
164
+ await seed(instance);
126
165
  assert.equal(await instance.connect(), 'fresh-token');
127
166
  assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
128
167
  });
@@ -131,9 +170,9 @@ for(const status of [400, 403]) {
131
170
  test(`rejected renewal (${status}) triggers a new persisted login`, async t => {
132
171
  const {client, seed, read, requests} = await fixture(t, {ttl : HOUR, renew_status : status});
133
172
  const instance = client();
134
- seed(instance);
173
+ await seed(instance);
135
174
  assert.equal(await instance.connect(), 'fresh-token');
136
- assert.equal(read(instance).token, 'fresh-token');
175
+ assert.equal((await read(instance)).token, 'fresh-token');
137
176
  assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self', '/v1/auth/jwt/login']);
138
177
  });
139
178
  }
@@ -141,7 +180,7 @@ for(const status of [400, 403]) {
141
180
  test('server failures do not generate extra tokens, and connect can be retried', async t => {
142
181
  const {client, seed, requests} = await fixture(t, {lookup_status : 500});
143
182
  const instance = client();
144
- seed(instance);
183
+ await seed(instance);
145
184
  instance.VAULT_TOKEN = 'uncached-token';
146
185
  await assert.rejects(instance.connect());
147
186
  await assert.rejects(instance.connect());
@@ -151,7 +190,7 @@ test('server failures do not generate extra tokens, and connect can be retried',
151
190
  test('renewal server failures do not generate extra tokens', async t => {
152
191
  const {client, seed, requests} = await fixture(t, {ttl : HOUR, renew_status : 500});
153
192
  const instance = client();
154
- seed(instance);
193
+ await seed(instance);
155
194
  await assert.rejects(instance.connect());
156
195
  assert.deepEqual(requests.map(req => req.path), ['/v1/auth/token/renew-self']);
157
196
  });
@@ -159,10 +198,10 @@ test('renewal server failures do not generate extra tokens', async t => {
159
198
  test('an existing environment token is respected and cached', async t => {
160
199
  const {client, seed, read, requests} = await fixture(t);
161
200
  const instance = client();
162
- seed(instance);
201
+ await seed(instance);
163
202
  instance.VAULT_TOKEN = 'environment-token';
164
203
  assert.equal(await instance.connect(), 'environment-token');
165
- assert.equal(read(instance).token, 'environment-token');
204
+ assert.equal((await read(instance)).token, 'environment-token');
166
205
  assert.equal(requests[0].token, 'environment-token');
167
206
  assert.equal(requests.length, 1);
168
207
  });
@@ -170,34 +209,35 @@ test('an existing environment token is respected and cached', async t => {
170
209
  test('nonexpiring tokens are reused without renewal', async t => {
171
210
  const {client, seed, read, requests} = await fixture(t, {ttl : 0});
172
211
  const instance = client();
173
- seed(instance, 0);
212
+ await seed(instance, 0);
174
213
  assert.equal(await instance.connect(), 'cached-token');
175
- assert.equal(read(instance).expires_at, null);
214
+ assert.equal((await read(instance)).expires_at, null);
176
215
  assert.equal(requests.length, 0);
177
216
  });
178
217
 
179
218
  test('corrupt caches are replaced with a valid cache', async t => {
180
219
  const {client, read, file} = await fixture(t);
181
- fs.mkdirSync(path.dirname(file));
220
+ fs.mkdirSync(path.dirname(file), {recursive : true});
182
221
  fs.writeFileSync(file, '{broken');
183
222
  const instance = client();
184
223
  assert.equal(await instance.connect(), 'fresh-token');
185
- assert.equal(read(instance).token, 'fresh-token');
224
+ assert.equal((await read(instance)).token, 'fresh-token');
186
225
  });
187
226
 
188
227
  test('cache entries are isolated by Vault address and authentication configuration', async t => {
189
228
  const {client, seed, read, file} = await fixture(t);
190
229
  const first = client();
191
- seed(first);
230
+ await seed(first);
192
231
  const second = client();
193
232
  second.rc.jwt_auth.role = 'other-role';
194
233
  assert.equal(await second.connect(), 'fresh-token');
195
- assert.equal(read(first).token, 'cached-token');
196
- assert.equal(read(second).token, 'fresh-token');
234
+ assert.notEqual(first._token_cache_file(), second._token_cache_file());
235
+ assert.equal((await read(first)).token, 'cached-token');
236
+ assert.equal((await read(second)).token, 'fresh-token');
197
237
  const third = client();
198
238
  third.VAULT_ADDR = 'http://other-vault.test';
199
239
  assert.notEqual(third._token_cache_key(), first._token_cache_key());
200
- assert.equal(Object.keys(JSON.parse(fs.readFileSync(file, 'utf8')).tokens).length, 2);
240
+ assert.equal(fs.readdirSync(path.dirname(file)).length, 2);
201
241
  assert.ok(!fs.readFileSync(file, 'utf8').includes('test-jwt'));
202
242
  });
203
243
 
@@ -211,38 +251,41 @@ test('repeated connect calls reuse the established connection', async t => {
211
251
 
212
252
  test('an unwritable cache does not prevent login', async t => {
213
253
  const {client, file} = await fixture(t);
254
+ fs.mkdirSync(path.dirname(path.dirname(file)), {recursive : true});
214
255
  fs.writeFileSync(path.dirname(file), 'not a directory');
215
256
  assert.equal(await client().connect(), 'fresh-token');
216
257
  });
217
258
 
218
- test('an invalid token without an auth method fails rather than exporting undefined', async t => {
259
+ test('without an auth method inherited tokens and caches are ignored', async t => {
219
260
  const {client, seed} = await fixture(t, {lookup_status : 403});
220
261
  const instance = client();
221
262
  instance.rc = {};
222
- seed(instance);
263
+ await seed(instance);
223
264
  instance.VAULT_TOKEN = 'uncached-token';
224
- await assert.rejects(instance.connect(), /no authentication method/);
265
+ instance._read_token_cache = async () => { throw new Error('No auth: no cache lookup'); };
266
+ assert.equal(await instance.connect(), undefined);
267
+ assert.equal(instance.VAULT_TOKEN, undefined);
225
268
  });
226
269
 
227
270
  test('--renew ignores both environment and cached tokens and saves a new token', async t => {
228
271
  const {client, seed, read, requests} = await fixture(t);
229
272
  const instance = client();
230
- seed(instance);
273
+ await seed(instance);
231
274
  instance.VAULT_TOKEN = 'environment-token';
232
275
  assert.equal(await instance.connect(true), 'fresh-token');
233
276
  assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
234
- assert.equal(read(instance).token, 'fresh-token');
235
- assert.ok(read(instance).expires_at > Date.now() + 23 * HOUR * 1000);
277
+ assert.equal((await read(instance)).token, 'fresh-token');
278
+ assert.ok((await read(instance)).expires_at > Date.now() + 23 * HOUR * 1000);
236
279
  });
237
280
 
238
281
  test('--renew replaces the token of a previously connected instance', async t => {
239
282
  const {client, seed, read, requests} = await fixture(t);
240
283
  const instance = client();
241
- seed(instance);
284
+ await seed(instance);
242
285
  await instance.connect();
243
286
  assert.equal(await instance.connect(true), 'fresh-token');
244
287
  assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
245
- assert.equal(read(instance).token, 'fresh-token');
288
+ assert.equal((await read(instance)).token, 'fresh-token');
246
289
  });
247
290
 
248
291
  test('each successive --renew call forces a new login', async t => {
@@ -253,11 +296,11 @@ test('each successive --renew call forces a new login', async t => {
253
296
  assert.equal(requests.length, 2);
254
297
  });
255
298
 
256
- test('--renew without an authentication method fails even with no cached token', async t => {
299
+ test('--renew without an authentication method remains a no-op', async t => {
257
300
  const {client} = await fixture(t);
258
301
  const instance = client();
259
302
  instance.rc = {};
260
- await assert.rejects(instance.connect(true), /no authentication method/);
303
+ assert.equal(await instance.connect(true), undefined);
261
304
  });
262
305
 
263
306
  for(const flag of ['renew']) {
@@ -274,13 +317,13 @@ for(const flag of ['renew']) {
274
317
  test(`real CLI ${command} --${flag} generates and caches a new token`, async t => {
275
318
  const {client, seed, read, requests, file} = await fixture(t);
276
319
  const instance = client();
277
- seed(instance);
278
- const home = path.dirname(path.dirname(file));
320
+ await seed(instance);
321
+ const home = path.dirname(path.dirname(path.dirname(file)));
279
322
  const rc = path.join(home, '.vauthrc');
280
323
  fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
281
324
  const options = {
282
325
  cwd : home,
283
- env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : 'environment-token', SSH_AUTH_SOCK : ''},
326
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : 'environment-token', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK},
284
327
  };
285
328
  const cli = path.resolve(__dirname, '..', 'index.js');
286
329
  const output = await exec(process.execPath, [cli, command, '--source', `--${flag}`], options);
@@ -292,7 +335,7 @@ for(const flag of ['renew']) {
292
335
  assert.ok(!output.stderr.includes('environment-token'));
293
336
  assert.deepEqual(requests.filter(req => !req.path.startsWith('/v1/identity/')).map(req => req.path), command === 'env'
294
337
  ? ['/v1/auth/jwt/login', '/v1/auth/token/lookup-self'] : ['/v1/auth/jwt/login']);
295
- assert.equal(read(instance).token, 'fresh-token');
338
+ assert.equal((await read(instance)).token, 'fresh-token');
296
339
  assert.equal(fs.statSync(file).mode & 0o777, 0o600);
297
340
  requests.length = 0;
298
341
  const reused = await exec(process.execPath, [cli, command, '--source'], {
@@ -309,8 +352,8 @@ for(const flag of ['renew']) {
309
352
  test('installed venv forwards --renew through the real CLI', async t => {
310
353
  const {client, seed, read, requests, file} = await fixture(t);
311
354
  const instance = client();
312
- seed(instance);
313
- const home = path.dirname(path.dirname(file));
355
+ await seed(instance);
356
+ const home = path.dirname(path.dirname(path.dirname(file)));
314
357
  const original_home = os.homedir;
315
358
  instance._function_exists = async () => 1;
316
359
  try {
@@ -328,7 +371,7 @@ test('installed venv forwards --renew through the real CLI', async t => {
328
371
  fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
329
372
  const options = {
330
373
  cwd : home,
331
- env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : '', PATH : `${bin}:${process.env.PATH}`},
374
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK, PATH : `${bin}:${process.env.PATH}`},
332
375
  };
333
376
  const initial = await exec('bash', ['-c', `${declaration}\nvenv; printf '%s' "$VAULT_TOKEN"`], options);
334
377
  assert.equal(initial.stdout, 'cached-token');
@@ -341,7 +384,7 @@ test('installed venv forwards --renew through the real CLI', async t => {
341
384
  assert.equal(output.stderr.split('\n').filter(line => line === `Vauth token: generated · ${instance.VAULT_ADDR} · 1d`).length, 1);
342
385
  assert.ok(!output.stderr.includes('Vauth token: renewed'));
343
386
  assert.ok(!output.stderr.includes('fresh-token'));
344
- assert.equal(read(instance).token, 'fresh-token');
387
+ assert.equal((await read(instance)).token, 'fresh-token');
345
388
  }
346
389
  assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 1);
347
390
  assert.ok(!requests.some(req => req.path.endsWith('/renew-self')));
@@ -350,7 +393,7 @@ test('installed venv forwards --renew through the real CLI', async t => {
350
393
  for(const answer of ['yes', 'no', '']) {
351
394
  test(`install asks before overriding an existing function: ${JSON.stringify(answer)}`, async t => {
352
395
  const {file} = await fixture(t);
353
- const home = path.dirname(path.dirname(file));
396
+ const home = path.dirname(path.dirname(path.dirname(file)));
354
397
  const bashrc = path.join(home, '.bashrc');
355
398
  const original = '# before\nfunction venv() { source <(/usr/bin/env vauth env --source); }\n# after\n';
356
399
  fs.writeFileSync(bashrc, original);
@@ -430,7 +473,7 @@ for(const options of [
430
473
  test(`one final status per connection: ${JSON.stringify(options)}`, async t => {
431
474
  const {client, seed} = await fixture(t, options);
432
475
  const instance = client();
433
- seed(instance);
476
+ await seed(instance);
434
477
  const calls = [];
435
478
  instance._token_status = (operation, metadata) => {
436
479
  calls.push(...captureStatus(() => Vauth.prototype._token_status.call(instance, operation, metadata)));
@@ -445,7 +488,7 @@ for(const options of [
445
488
  test('failed connections never announce successful token status', async t => {
446
489
  const {client, seed} = await fixture(t, {lookup_status : 500});
447
490
  const instance = client();
448
- seed(instance);
491
+ await seed(instance);
449
492
  instance.VAULT_TOKEN = 'uncached-token';
450
493
  const calls = [];
451
494
  instance._token_status = operation => calls.push(operation);
@@ -464,10 +507,10 @@ for(const [lease, remaining, renew] of [
464
507
  test(`proportional threshold: lease=${lease}, remaining=${remaining}`, async t => {
465
508
  const {client, seed, read, requests} = await fixture(t, {ttl : remaining});
466
509
  const instance = client();
467
- seed(instance, lease);
510
+ await seed(instance, lease);
468
511
  await instance.connect();
469
512
  assert.equal(requests.some(req => req.path.endsWith('/renew-self')), renew);
470
- assert.equal(read(instance).lease_duration, renew ? 24 * HOUR : lease);
513
+ assert.equal((await read(instance)).lease_duration, renew ? 24 * HOUR : lease);
471
514
  });
472
515
  }
473
516
 
@@ -475,13 +518,13 @@ test('lookups never reset the original lease duration', async t => {
475
518
  const options = {ttl : 20 * HOUR};
476
519
  const {client, seed, read, requests, remaining} = await fixture(t, options);
477
520
  const instance = client();
478
- seed(instance, 24 * HOUR);
521
+ await seed(instance, 24 * HOUR);
479
522
  await instance.connect();
480
- assert.equal(read(instance).lease_duration, 24 * HOUR);
481
- remaining(instance, 12 * HOUR);
523
+ assert.equal((await read(instance)).lease_duration, 24 * HOUR);
524
+ await remaining(instance, 12 * HOUR);
482
525
  await client().connect();
483
- assert.equal(read(instance).lease_duration, 24 * HOUR);
484
- remaining(instance, 8 * HOUR);
526
+ assert.equal((await read(instance)).lease_duration, 24 * HOUR);
527
+ await remaining(instance, 8 * HOUR);
485
528
  await client().connect();
486
529
  assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 1);
487
530
  });
@@ -490,10 +533,10 @@ test('renewal updates the lease baseline to the newly granted duration', async t
490
533
  const options = {ttl : 20 * 60};
491
534
  const {client, seed, read, requests, remaining} = await fixture(t, options);
492
535
  const instance = client();
493
- seed(instance, HOUR);
536
+ await seed(instance, HOUR);
494
537
  await instance.connect();
495
- assert.equal(read(instance).lease_duration, 24 * HOUR);
496
- remaining(instance, 8 * HOUR);
538
+ assert.equal((await read(instance)).lease_duration, 24 * HOUR);
539
+ await remaining(instance, 8 * HOUR);
497
540
  await client().connect();
498
541
  assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 2);
499
542
  });
@@ -501,7 +544,7 @@ test('renewal updates the lease baseline to the newly granted duration', async t
501
544
  test('normal reuse never writes the token cache', async t => {
502
545
  const {client, seed, file, requests} = await fixture(t);
503
546
  const instance = client();
504
- seed(instance);
547
+ await seed(instance);
505
548
  instance.VAULT_TOKEN = 'cached-token';
506
549
  const before = fs.readFileSync(file, 'utf8');
507
550
  const stat = fs.statSync(file);
@@ -520,7 +563,7 @@ test('lookup returns the lease duration and preserves a cached renewal baseline'
520
563
  const uncached = await instance._lookup_token('environment-token');
521
564
  assert.equal(uncached.lease_duration, 24 * HOUR);
522
565
  assert.equal(uncached.ttl, HOUR);
523
- seed(instance, 12 * HOUR);
566
+ await seed(instance, 12 * HOUR);
524
567
  const cached = await instance._lookup_token('cached-token');
525
568
  assert.equal(cached.lease_duration, 12 * HOUR);
526
569
  assert.equal(requests.length, 2);
@@ -539,7 +582,7 @@ test('token_cache false skips all cache filesystem access', async t => {
539
582
  test('token_cache false ignores existing tokens on disk without deleting them', async t => {
540
583
  const {client, seed, requests, file} = await fixture(t);
541
584
  const instance = client();
542
- seed(instance);
585
+ await seed(instance);
543
586
  const before = fs.readFileSync(file, 'utf8');
544
587
  instance.rc.token_cache = false;
545
588
  assert.equal(await instance.connect(), 'fresh-token');
@@ -563,14 +606,14 @@ test('token_cache false still respects an environment token without persisting i
563
606
  test('real CLI reads token_cache false from .vauthrc', async t => {
564
607
  const {client, file, requests} = await fixture(t);
565
608
  const instance = client();
566
- const home = path.dirname(path.dirname(file));
609
+ const home = path.dirname(path.dirname(path.dirname(file)));
567
610
  const rc = path.join(home, '.vauthrc');
568
611
  fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : false}));
569
612
  const cli = path.resolve(__dirname, '..', 'index.js');
570
613
  for(let i = 0; i < 2; i++) {
571
614
  const output = await exec(process.execPath, [cli, 'login', '--source'], {
572
615
  cwd : home,
573
- env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : ''},
616
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK},
574
617
  });
575
618
  assert.ok(output.stdout.includes("export VAULT_TOKEN='fresh-token'"));
576
619
  assert.ok(output.stderr.includes('Vauth token: generated'));
@@ -578,3 +621,174 @@ test('real CLI reads token_cache false from .vauthrc', async t => {
578
621
  assert.equal(fs.existsSync(file), false);
579
622
  assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 2);
580
623
  });
624
+
625
+ test('disk cache contains only armor, interoperable with ssh-agent-crypt CLI', async t => {
626
+ const {client, file} = await fixture(t);
627
+ const instance = client();
628
+ await instance.connect();
629
+ const armor = fs.readFileSync(file, 'utf8');
630
+ assert.match(armor, /^ssh-agent-crypt:v2:/);
631
+ assert.ok(!armor.includes('fresh-token'));
632
+ assert.ok(!armor.includes('lease_duration'));
633
+ assert.ok(!armor.includes('test-jwt'));
634
+ const cli = require.resolve('ssh-agent-crypt/bin/ssh-agent-crypt');
635
+ const decoded = spawnSync('bash', [cli, '-decrypt'], {input : armor, encoding : 'utf8'});
636
+ assert.equal(decoded.status, 0);
637
+ assert.equal(JSON.parse(decoded.stdout).token, 'fresh-token');
638
+ });
639
+
640
+ test('a plaintext cache is not accepted as a token cache', async t => {
641
+ const {client, file, requests} = await fixture(t);
642
+ const instance = client();
643
+ fs.mkdirSync(path.dirname(file), {recursive : true});
644
+ fs.writeFileSync(file, JSON.stringify({token : 'plaintext-token', lease_duration : 86400, expires_at : Date.now() + 86400000}));
645
+ assert.equal(await instance.connect(), 'fresh-token');
646
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
647
+ assert.match(fs.readFileSync(file, 'utf8'), /^ssh-agent-crypt:v2:/);
648
+ });
649
+
650
+ test('without an SSH agent cache is skipped silently', async t => {
651
+ const {client, file} = await fixture(t);
652
+ const instance = client();
653
+ instance._token_cache_file = () => { throw new Error('No agent: no disk access'); };
654
+ const socket = process.env.SSH_AUTH_SOCK;
655
+ const original = console.error;
656
+ const messages = [];
657
+ try {
658
+ delete process.env.SSH_AUTH_SOCK;
659
+ instance.rc.token_cache = false;
660
+ console.error = (...args) => messages.push(format(...args));
661
+ assert.equal(await instance.connect(), 'fresh-token');
662
+ } finally {
663
+ process.env.SSH_AUTH_SOCK = socket;
664
+ console.error = original;
665
+ }
666
+ assert.equal(fs.existsSync(file), false);
667
+ assert.equal(fs.existsSync(file + '.next'), false);
668
+ assert.ok(!messages.some(message => message.includes('token not persisted')));
669
+ assert.ok(!messages.join('\n').includes('fresh-token'));
670
+ });
671
+
672
+ test('without an SSH agent existing armor is left untouched', async t => {
673
+ const {client, seed, file} = await fixture(t);
674
+ const instance = client();
675
+ await seed(instance);
676
+ const before = fs.readFileSync(file, 'utf8');
677
+ const socket = process.env.SSH_AUTH_SOCK;
678
+ try {
679
+ delete process.env.SSH_AUTH_SOCK;
680
+ instance.rc.token_cache = false;
681
+ await instance._save_cached_token('replacement-token', {lease_duration : 86400, renewable : true});
682
+ } finally {
683
+ process.env.SSH_AUTH_SOCK = socket;
684
+ }
685
+ assert.equal(fs.readFileSync(file, 'utf8'), before);
686
+ assert.equal(fs.existsSync(file + '.next'), false);
687
+ });
688
+
689
+
690
+ test('constructor disables token caching when no SSH agent is configured', async t => {
691
+ const {client, file} = await fixture(t);
692
+ const instance = client();
693
+ const home = path.dirname(path.dirname(path.dirname(file)));
694
+ const rc = path.join(home, '.vauthrc');
695
+ fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : true}));
696
+ const module_path = path.resolve(__dirname, '..', 'index.js');
697
+ const script = `const Vauth = require(${JSON.stringify(module_path)});
698
+ const instance = new Vauth();
699
+ process.stdout.write(String(instance.rc.token_cache));
700
+ instance.login().catch(() => process.exit(1));`;
701
+ const output = await exec(process.execPath, ['-e', script], {
702
+ cwd : home,
703
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : ''},
704
+ });
705
+ assert.equal(output.stdout.trim(), 'false');
706
+ assert.ok(!output.stderr.includes('token not persisted'));
707
+ assert.equal(fs.existsSync(file), false);
708
+ });
709
+
710
+
711
+ for(const selected of ['identity', 'mail', 'default']) {
712
+ test(`cache encryption selects ${selected}; decryption uses embedded fingerprint`, async t => {
713
+ const {client, file} = await fixture(t);
714
+ const instance = client();
715
+ const directory = path.dirname(path.dirname(path.dirname(file)));
716
+ const key = path.join(directory, 'selected-key');
717
+ const comment = 'selected-cache@test';
718
+ assert.equal(spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', comment, '-f', key]).status, 0);
719
+ assert.equal(spawnSync('ssh-add', [key]).status, 0);
720
+ try {
721
+ if(selected === 'identity') {
722
+ process.env.VAUTH_USER_IDENTITY = comment;
723
+ process.env.VAUTH_USER_MAIL = 'missing-key@test';
724
+ } else if(selected === 'mail') {
725
+ process.env.VAUTH_USER_MAIL = comment;
726
+ }
727
+ assert.equal(await instance.connect(), 'fresh-token');
728
+ const fingerprint = spawnSync('ssh-keygen', ['-lf', key + '.pub'], {encoding : 'utf8'}).stdout.split(/\s+/)[1];
729
+ const armor = fs.readFileSync(file, 'utf8');
730
+ assert.equal(armor.startsWith(`ssh-agent-crypt:v2:${fingerprint}.`), selected !== 'default');
731
+ delete process.env.VAUTH_USER_IDENTITY;
732
+ delete process.env.VAUTH_USER_MAIL;
733
+ assert.equal(await client().connect(), 'fresh-token');
734
+ } finally {
735
+ spawnSync('ssh-add', ['-d', key]);
736
+ delete process.env.VAUTH_USER_IDENTITY;
737
+ delete process.env.VAUTH_USER_MAIL;
738
+ }
739
+ });
740
+ }
741
+
742
+ for(const explicit of [false, true]) {
743
+ test(`.creds uses the JS module without a CLI (explicit identity=${explicit})`, async t => {
744
+ const {client, file} = await fixture(t);
745
+ const instance = client();
746
+ const credentials = path.join(path.dirname(path.dirname(path.dirname(file))), '.creds');
747
+ const identity = spawnSync('ssh-add', ['-l'], {encoding : 'utf8'}).stdout.split(/\s+/)[1];
748
+ fs.writeFileSync(credentials, await encrypt(JSON.stringify({TEST_SECRET : 'module-secret'})));
749
+ instance.rc['ssh-agent-crypt'] = {path : credentials};
750
+ if(explicit)
751
+ instance.rc['ssh-agent-crypt'].identity = identity;
752
+ instance.rc.env = {map : {EXPORTED_SECRET : '$${secrets.TEST_SECRET}'}};
753
+ instance._vault_get_profile = async () => ({profile : {}, database : {}});
754
+ const previousPath = process.env.PATH;
755
+ try {
756
+ process.env.PATH = path.dirname(credentials);
757
+ const env = await instance.env();
758
+ assert.equal(env.EXPORTED_SECRET, 'module-secret');
759
+ } finally {
760
+ process.env.PATH = previousPath;
761
+ }
762
+ });
763
+ }
764
+
765
+ test('.creds decryption failures reject instead of exiting successfully', async t => {
766
+ const {client, file} = await fixture(t);
767
+ const instance = client();
768
+ const credentials = path.join(path.dirname(path.dirname(path.dirname(file))), '.creds');
769
+ fs.writeFileSync(credentials, 'not an armored payload');
770
+ instance.rc['ssh-agent-crypt'] = {path : credentials};
771
+ instance._vault_get_profile = async () => ({profile : {}, database : {}});
772
+ await assert.rejects(instance.env(), /Unsupported format header/);
773
+ });
774
+
775
+ for(const inherited of ['', 'inherited-token']) {
776
+ test(`local .creds environment works offline with inherited token=${!!inherited}`, async t => {
777
+ const {client, file, requests} = await fixture(t, {lookup_status : 503});
778
+ const instance = client();
779
+ const credentials = path.join(path.dirname(path.dirname(path.dirname(file))), '.creds');
780
+ fs.writeFileSync(credentials, await encrypt(JSON.stringify({ARM_CLIENT_SECRET : 'local-secret', VAULT_TOKEN : 'local-token'})));
781
+ instance.rc = {
782
+ 'ssh-agent-crypt' : {path : credentials},
783
+ env : {map : {ARM_CLIENT_SECRET : '$${secrets.ARM_CLIENT_SECRET}', VAULT_TOKEN : '$${secrets.VAULT_TOKEN}'}},
784
+ };
785
+ instance.VAULT_TOKEN = inherited;
786
+ instance._lookup_token = async () => { throw new Error('Local environment must not contact Vault'); };
787
+ instance._read_token_cache = async () => { throw new Error('Local environment must not read the token cache'); };
788
+ const env = await instance.env();
789
+ assert.equal(env.ARM_CLIENT_SECRET, 'local-secret');
790
+ assert.equal(env.VAULT_TOKEN, 'local-token');
791
+ assert.equal(env.VAULT_ADDR, instance.VAULT_ADDR);
792
+ assert.deepEqual(requests, []);
793
+ });
794
+ }