vvauth 2.2.0 → 2.3.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,6 +6,7 @@ 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
12
  const wait = require('nyks/child_process/wait');
@@ -89,6 +90,9 @@ class vvauth {
89
90
  }
90
91
 
91
92
 
93
+ if(!process.env.SSH_AUTH_SOCK)
94
+ this.rc.token_cache = false;
95
+
92
96
  this.VAULT_ADDR = this.rc.vault_addr || process.env.VAULT_ADDR; //might be null
93
97
  this.VAULT_TOKEN = process.env.VAULT_TOKEN;
94
98
 
@@ -112,40 +116,38 @@ class vvauth {
112
116
  }
113
117
 
114
118
  _token_cache_file() {
115
- return path.join(os.homedir(), '.vauth', 'token.json');
119
+ return path.join(os.homedir(), '.vauth', 'tokens', this._token_cache_key() + '.creds');
116
120
  }
117
121
 
118
- _read_token_cache() {
122
+ async _read_token_cache() {
119
123
  if(this.rc.token_cache === false)
120
- return {version : 1, tokens : {}};
124
+ return;
121
125
  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;
126
+ const armor = fs.readFileSync(this._token_cache_file(), 'utf8');
127
+ return JSON.parse(await decrypt(armor));
125
128
  } catch(err) {}
126
- return {version : 1, tokens : {}};
127
129
  }
128
130
 
129
- _save_cached_token(token, metadata) {
131
+ async _save_cached_token(token, metadata) {
130
132
  if(this.rc.token_cache === false)
131
133
  return;
132
134
  const ttl = metadata.ttl === undefined ? metadata.lease_duration : metadata.ttl;
133
135
  const file = this._token_cache_file();
134
136
  const temporary = file + '.next';
135
137
  try {
136
- const cache = this._read_token_cache();
137
- const key = this._token_cache_key();
138
138
  const {lease_duration} = metadata;
139
- cache.tokens[key] = {
139
+ const cache = {
140
140
  token, lease_duration, renewable : metadata.renewable === true,
141
141
  expires_at : ttl === 0 ? null : Date.now() + ttl * 1000,
142
142
  };
143
143
  fs.mkdirSync(path.dirname(file), {recursive : true, mode : 0o700});
144
- fs.writeFileSync(temporary, JSON.stringify(cache, null, 2) + '\n', {mode : 0o600});
144
+ const identity = process.env.VAUTH_USER_IDENTITY || process.env.VAUTH_USER_MAIL;
145
+ const armor = await encrypt(JSON.stringify(cache), identity);
146
+ fs.writeFileSync(temporary, armor, {mode : 0o600});
145
147
  fs.renameSync(temporary, file);
146
148
  } catch(err) {
147
149
  // Cache failures must not prevent authentication or expose credentials.
148
- logger.error('Could not write token cache (%s)', err.code || 'invalid cache');
150
+ console.error('Could not encrypt/write token cache; token not persisted');
149
151
  } finally {
150
152
  try { fs.unlinkSync(temporary); } catch(err) {}
151
153
  }
@@ -162,7 +164,7 @@ class vvauth {
162
164
  return this.VAULT_TOKEN;
163
165
  this._connected = false;
164
166
 
165
- const cached = this._read_token_cache().tokens[this._token_cache_key()];
167
+ const cached = await this._read_token_cache();
166
168
  let token = this.VAULT_TOKEN || (cached && cached.token);
167
169
  const had_token = !!token;
168
170
  let metadata, operation = 'reused';
@@ -213,7 +215,7 @@ class vvauth {
213
215
 
214
216
  if(token) {
215
217
  if(operation !== 'reused' || !cached || cached.token !== token)
216
- this._save_cached_token(token, metadata);
218
+ await this._save_cached_token(token, metadata);
217
219
  this._token_status(operation, metadata);
218
220
  }
219
221
  this.VAULT_TOKEN = token;
@@ -445,7 +447,7 @@ class vvauth {
445
447
  let response = JSON.parse(await drain(res)).data;
446
448
  if(!Number.isFinite(response.ttl) || response.ttl < 0)
447
449
  throw new Error('Invalid Vault token TTL');
448
- const cached = this._read_token_cache().tokens[this._token_cache_key()];
450
+ const cached = await this._read_token_cache();
449
451
  if(cached && cached.token === token)
450
452
  response.lease_duration = cached.lease_duration;
451
453
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vvauth",
3
- "version": "2.2.0",
3
+ "version": "2.3.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,6 +251,7 @@ 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
  });
@@ -219,7 +260,7 @@ test('an invalid token without an auth method fails rather than exporting undefi
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
265
  await assert.rejects(instance.connect(), /no authentication method/);
225
266
  });
@@ -227,22 +268,22 @@ test('an invalid token without an auth method fails rather than exporting undefi
227
268
  test('--renew ignores both environment and cached tokens and saves a new token', async t => {
228
269
  const {client, seed, read, requests} = await fixture(t);
229
270
  const instance = client();
230
- seed(instance);
271
+ await seed(instance);
231
272
  instance.VAULT_TOKEN = 'environment-token';
232
273
  assert.equal(await instance.connect(true), 'fresh-token');
233
274
  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);
275
+ assert.equal((await read(instance)).token, 'fresh-token');
276
+ assert.ok((await read(instance)).expires_at > Date.now() + 23 * HOUR * 1000);
236
277
  });
237
278
 
238
279
  test('--renew replaces the token of a previously connected instance', async t => {
239
280
  const {client, seed, read, requests} = await fixture(t);
240
281
  const instance = client();
241
- seed(instance);
282
+ await seed(instance);
242
283
  await instance.connect();
243
284
  assert.equal(await instance.connect(true), 'fresh-token');
244
285
  assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
245
- assert.equal(read(instance).token, 'fresh-token');
286
+ assert.equal((await read(instance)).token, 'fresh-token');
246
287
  });
247
288
 
248
289
  test('each successive --renew call forces a new login', async t => {
@@ -274,13 +315,13 @@ for(const flag of ['renew']) {
274
315
  test(`real CLI ${command} --${flag} generates and caches a new token`, async t => {
275
316
  const {client, seed, read, requests, file} = await fixture(t);
276
317
  const instance = client();
277
- seed(instance);
278
- const home = path.dirname(path.dirname(file));
318
+ await seed(instance);
319
+ const home = path.dirname(path.dirname(path.dirname(file)));
279
320
  const rc = path.join(home, '.vauthrc');
280
321
  fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
281
322
  const options = {
282
323
  cwd : home,
283
- env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : 'environment-token', SSH_AUTH_SOCK : ''},
324
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : 'environment-token', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK},
284
325
  };
285
326
  const cli = path.resolve(__dirname, '..', 'index.js');
286
327
  const output = await exec(process.execPath, [cli, command, '--source', `--${flag}`], options);
@@ -292,7 +333,7 @@ for(const flag of ['renew']) {
292
333
  assert.ok(!output.stderr.includes('environment-token'));
293
334
  assert.deepEqual(requests.filter(req => !req.path.startsWith('/v1/identity/')).map(req => req.path), command === 'env'
294
335
  ? ['/v1/auth/jwt/login', '/v1/auth/token/lookup-self'] : ['/v1/auth/jwt/login']);
295
- assert.equal(read(instance).token, 'fresh-token');
336
+ assert.equal((await read(instance)).token, 'fresh-token');
296
337
  assert.equal(fs.statSync(file).mode & 0o777, 0o600);
297
338
  requests.length = 0;
298
339
  const reused = await exec(process.execPath, [cli, command, '--source'], {
@@ -309,8 +350,8 @@ for(const flag of ['renew']) {
309
350
  test('installed venv forwards --renew through the real CLI', async t => {
310
351
  const {client, seed, read, requests, file} = await fixture(t);
311
352
  const instance = client();
312
- seed(instance);
313
- const home = path.dirname(path.dirname(file));
353
+ await seed(instance);
354
+ const home = path.dirname(path.dirname(path.dirname(file)));
314
355
  const original_home = os.homedir;
315
356
  instance._function_exists = async () => 1;
316
357
  try {
@@ -328,7 +369,7 @@ test('installed venv forwards --renew through the real CLI', async t => {
328
369
  fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc}));
329
370
  const options = {
330
371
  cwd : home,
331
- env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : '', PATH : `${bin}:${process.env.PATH}`},
372
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK, PATH : `${bin}:${process.env.PATH}`},
332
373
  };
333
374
  const initial = await exec('bash', ['-c', `${declaration}\nvenv; printf '%s' "$VAULT_TOKEN"`], options);
334
375
  assert.equal(initial.stdout, 'cached-token');
@@ -341,7 +382,7 @@ test('installed venv forwards --renew through the real CLI', async t => {
341
382
  assert.equal(output.stderr.split('\n').filter(line => line === `Vauth token: generated · ${instance.VAULT_ADDR} · 1d`).length, 1);
342
383
  assert.ok(!output.stderr.includes('Vauth token: renewed'));
343
384
  assert.ok(!output.stderr.includes('fresh-token'));
344
- assert.equal(read(instance).token, 'fresh-token');
385
+ assert.equal((await read(instance)).token, 'fresh-token');
345
386
  }
346
387
  assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 1);
347
388
  assert.ok(!requests.some(req => req.path.endsWith('/renew-self')));
@@ -350,7 +391,7 @@ test('installed venv forwards --renew through the real CLI', async t => {
350
391
  for(const answer of ['yes', 'no', '']) {
351
392
  test(`install asks before overriding an existing function: ${JSON.stringify(answer)}`, async t => {
352
393
  const {file} = await fixture(t);
353
- const home = path.dirname(path.dirname(file));
394
+ const home = path.dirname(path.dirname(path.dirname(file)));
354
395
  const bashrc = path.join(home, '.bashrc');
355
396
  const original = '# before\nfunction venv() { source <(/usr/bin/env vauth env --source); }\n# after\n';
356
397
  fs.writeFileSync(bashrc, original);
@@ -430,7 +471,7 @@ for(const options of [
430
471
  test(`one final status per connection: ${JSON.stringify(options)}`, async t => {
431
472
  const {client, seed} = await fixture(t, options);
432
473
  const instance = client();
433
- seed(instance);
474
+ await seed(instance);
434
475
  const calls = [];
435
476
  instance._token_status = (operation, metadata) => {
436
477
  calls.push(...captureStatus(() => Vauth.prototype._token_status.call(instance, operation, metadata)));
@@ -445,7 +486,7 @@ for(const options of [
445
486
  test('failed connections never announce successful token status', async t => {
446
487
  const {client, seed} = await fixture(t, {lookup_status : 500});
447
488
  const instance = client();
448
- seed(instance);
489
+ await seed(instance);
449
490
  instance.VAULT_TOKEN = 'uncached-token';
450
491
  const calls = [];
451
492
  instance._token_status = operation => calls.push(operation);
@@ -464,10 +505,10 @@ for(const [lease, remaining, renew] of [
464
505
  test(`proportional threshold: lease=${lease}, remaining=${remaining}`, async t => {
465
506
  const {client, seed, read, requests} = await fixture(t, {ttl : remaining});
466
507
  const instance = client();
467
- seed(instance, lease);
508
+ await seed(instance, lease);
468
509
  await instance.connect();
469
510
  assert.equal(requests.some(req => req.path.endsWith('/renew-self')), renew);
470
- assert.equal(read(instance).lease_duration, renew ? 24 * HOUR : lease);
511
+ assert.equal((await read(instance)).lease_duration, renew ? 24 * HOUR : lease);
471
512
  });
472
513
  }
473
514
 
@@ -475,13 +516,13 @@ test('lookups never reset the original lease duration', async t => {
475
516
  const options = {ttl : 20 * HOUR};
476
517
  const {client, seed, read, requests, remaining} = await fixture(t, options);
477
518
  const instance = client();
478
- seed(instance, 24 * HOUR);
519
+ await seed(instance, 24 * HOUR);
479
520
  await instance.connect();
480
- assert.equal(read(instance).lease_duration, 24 * HOUR);
481
- remaining(instance, 12 * HOUR);
521
+ assert.equal((await read(instance)).lease_duration, 24 * HOUR);
522
+ await remaining(instance, 12 * HOUR);
482
523
  await client().connect();
483
- assert.equal(read(instance).lease_duration, 24 * HOUR);
484
- remaining(instance, 8 * HOUR);
524
+ assert.equal((await read(instance)).lease_duration, 24 * HOUR);
525
+ await remaining(instance, 8 * HOUR);
485
526
  await client().connect();
486
527
  assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 1);
487
528
  });
@@ -490,10 +531,10 @@ test('renewal updates the lease baseline to the newly granted duration', async t
490
531
  const options = {ttl : 20 * 60};
491
532
  const {client, seed, read, requests, remaining} = await fixture(t, options);
492
533
  const instance = client();
493
- seed(instance, HOUR);
534
+ await seed(instance, HOUR);
494
535
  await instance.connect();
495
- assert.equal(read(instance).lease_duration, 24 * HOUR);
496
- remaining(instance, 8 * HOUR);
536
+ assert.equal((await read(instance)).lease_duration, 24 * HOUR);
537
+ await remaining(instance, 8 * HOUR);
497
538
  await client().connect();
498
539
  assert.equal(requests.filter(req => req.path.endsWith('/renew-self')).length, 2);
499
540
  });
@@ -501,7 +542,7 @@ test('renewal updates the lease baseline to the newly granted duration', async t
501
542
  test('normal reuse never writes the token cache', async t => {
502
543
  const {client, seed, file, requests} = await fixture(t);
503
544
  const instance = client();
504
- seed(instance);
545
+ await seed(instance);
505
546
  instance.VAULT_TOKEN = 'cached-token';
506
547
  const before = fs.readFileSync(file, 'utf8');
507
548
  const stat = fs.statSync(file);
@@ -520,7 +561,7 @@ test('lookup returns the lease duration and preserves a cached renewal baseline'
520
561
  const uncached = await instance._lookup_token('environment-token');
521
562
  assert.equal(uncached.lease_duration, 24 * HOUR);
522
563
  assert.equal(uncached.ttl, HOUR);
523
- seed(instance, 12 * HOUR);
564
+ await seed(instance, 12 * HOUR);
524
565
  const cached = await instance._lookup_token('cached-token');
525
566
  assert.equal(cached.lease_duration, 12 * HOUR);
526
567
  assert.equal(requests.length, 2);
@@ -539,7 +580,7 @@ test('token_cache false skips all cache filesystem access', async t => {
539
580
  test('token_cache false ignores existing tokens on disk without deleting them', async t => {
540
581
  const {client, seed, requests, file} = await fixture(t);
541
582
  const instance = client();
542
- seed(instance);
583
+ await seed(instance);
543
584
  const before = fs.readFileSync(file, 'utf8');
544
585
  instance.rc.token_cache = false;
545
586
  assert.equal(await instance.connect(), 'fresh-token');
@@ -563,14 +604,14 @@ test('token_cache false still respects an environment token without persisting i
563
604
  test('real CLI reads token_cache false from .vauthrc', async t => {
564
605
  const {client, file, requests} = await fixture(t);
565
606
  const instance = client();
566
- const home = path.dirname(path.dirname(file));
607
+ const home = path.dirname(path.dirname(path.dirname(file)));
567
608
  const rc = path.join(home, '.vauthrc');
568
609
  fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : false}));
569
610
  const cli = path.resolve(__dirname, '..', 'index.js');
570
611
  for(let i = 0; i < 2; i++) {
571
612
  const output = await exec(process.execPath, [cli, 'login', '--source'], {
572
613
  cwd : home,
573
- env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : ''},
614
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : process.env.SSH_AUTH_SOCK},
574
615
  });
575
616
  assert.ok(output.stdout.includes("export VAULT_TOKEN='fresh-token'"));
576
617
  assert.ok(output.stderr.includes('Vauth token: generated'));
@@ -578,3 +619,120 @@ test('real CLI reads token_cache false from .vauthrc', async t => {
578
619
  assert.equal(fs.existsSync(file), false);
579
620
  assert.equal(requests.filter(req => req.path.endsWith('/login')).length, 2);
580
621
  });
622
+
623
+ test('disk cache contains only armor, interoperable with ssh-agent-crypt CLI', async t => {
624
+ const {client, file} = await fixture(t);
625
+ const instance = client();
626
+ await instance.connect();
627
+ const armor = fs.readFileSync(file, 'utf8');
628
+ assert.match(armor, /^ssh-agent-crypt:v2:/);
629
+ assert.ok(!armor.includes('fresh-token'));
630
+ assert.ok(!armor.includes('lease_duration'));
631
+ assert.ok(!armor.includes('test-jwt'));
632
+ const cli = require.resolve('ssh-agent-crypt/bin/ssh-agent-crypt');
633
+ const decoded = spawnSync('bash', [cli, '-decrypt'], {input : armor, encoding : 'utf8'});
634
+ assert.equal(decoded.status, 0);
635
+ assert.equal(JSON.parse(decoded.stdout).token, 'fresh-token');
636
+ });
637
+
638
+ test('a plaintext cache is not accepted as a token cache', async t => {
639
+ const {client, file, requests} = await fixture(t);
640
+ const instance = client();
641
+ fs.mkdirSync(path.dirname(file), {recursive : true});
642
+ fs.writeFileSync(file, JSON.stringify({token : 'plaintext-token', lease_duration : 86400, expires_at : Date.now() + 86400000}));
643
+ assert.equal(await instance.connect(), 'fresh-token');
644
+ assert.deepEqual(requests.map(req => req.path), ['/v1/auth/jwt/login']);
645
+ assert.match(fs.readFileSync(file, 'utf8'), /^ssh-agent-crypt:v2:/);
646
+ });
647
+
648
+ test('without an SSH agent cache is skipped silently', async t => {
649
+ const {client, file} = await fixture(t);
650
+ const instance = client();
651
+ instance._token_cache_file = () => { throw new Error('No agent: no disk access'); };
652
+ const socket = process.env.SSH_AUTH_SOCK;
653
+ const original = console.error;
654
+ const messages = [];
655
+ try {
656
+ delete process.env.SSH_AUTH_SOCK;
657
+ instance.rc.token_cache = false;
658
+ console.error = (...args) => messages.push(format(...args));
659
+ assert.equal(await instance.connect(), 'fresh-token');
660
+ } finally {
661
+ process.env.SSH_AUTH_SOCK = socket;
662
+ console.error = original;
663
+ }
664
+ assert.equal(fs.existsSync(file), false);
665
+ assert.equal(fs.existsSync(file + '.next'), false);
666
+ assert.ok(!messages.some(message => message.includes('token not persisted')));
667
+ assert.ok(!messages.join('\n').includes('fresh-token'));
668
+ });
669
+
670
+ test('without an SSH agent existing armor is left untouched', async t => {
671
+ const {client, seed, file} = await fixture(t);
672
+ const instance = client();
673
+ await seed(instance);
674
+ const before = fs.readFileSync(file, 'utf8');
675
+ const socket = process.env.SSH_AUTH_SOCK;
676
+ try {
677
+ delete process.env.SSH_AUTH_SOCK;
678
+ instance.rc.token_cache = false;
679
+ await instance._save_cached_token('replacement-token', {lease_duration : 86400, renewable : true});
680
+ } finally {
681
+ process.env.SSH_AUTH_SOCK = socket;
682
+ }
683
+ assert.equal(fs.readFileSync(file, 'utf8'), before);
684
+ assert.equal(fs.existsSync(file + '.next'), false);
685
+ });
686
+
687
+
688
+ test('constructor disables token caching when no SSH agent is configured', async t => {
689
+ const {client, file} = await fixture(t);
690
+ const instance = client();
691
+ const home = path.dirname(path.dirname(path.dirname(file)));
692
+ const rc = path.join(home, '.vauthrc');
693
+ fs.writeFileSync(rc, JSON.stringify({vault_addr : instance.VAULT_ADDR, ...instance.rc, token_cache : true}));
694
+ const module_path = path.resolve(__dirname, '..', 'index.js');
695
+ const script = `const Vauth = require(${JSON.stringify(module_path)});
696
+ const instance = new Vauth();
697
+ process.stdout.write(String(instance.rc.token_cache));
698
+ instance.login().catch(() => process.exit(1));`;
699
+ const output = await exec(process.execPath, ['-e', script], {
700
+ cwd : home,
701
+ env : {...process.env, HOME : home, VAUTHRC : rc, VAULT_TOKEN : '', SSH_AUTH_SOCK : ''},
702
+ });
703
+ assert.equal(output.stdout.trim(), 'false');
704
+ assert.ok(!output.stderr.includes('token not persisted'));
705
+ assert.equal(fs.existsSync(file), false);
706
+ });
707
+
708
+
709
+ for(const selected of ['identity', 'mail', 'default']) {
710
+ test(`cache encryption selects ${selected}; decryption uses embedded fingerprint`, async t => {
711
+ const {client, file} = await fixture(t);
712
+ const instance = client();
713
+ const directory = path.dirname(path.dirname(path.dirname(file)));
714
+ const key = path.join(directory, 'selected-key');
715
+ const comment = 'selected-cache@test';
716
+ assert.equal(spawnSync('ssh-keygen', ['-q', '-t', 'ed25519', '-N', '', '-C', comment, '-f', key]).status, 0);
717
+ assert.equal(spawnSync('ssh-add', [key]).status, 0);
718
+ try {
719
+ if(selected === 'identity') {
720
+ process.env.VAUTH_USER_IDENTITY = comment;
721
+ process.env.VAUTH_USER_MAIL = 'missing-key@test';
722
+ } else if(selected === 'mail') {
723
+ process.env.VAUTH_USER_MAIL = comment;
724
+ }
725
+ assert.equal(await instance.connect(), 'fresh-token');
726
+ const fingerprint = spawnSync('ssh-keygen', ['-lf', key + '.pub'], {encoding : 'utf8'}).stdout.split(/\s+/)[1];
727
+ const armor = fs.readFileSync(file, 'utf8');
728
+ assert.equal(armor.startsWith(`ssh-agent-crypt:v2:${fingerprint}.`), selected !== 'default');
729
+ delete process.env.VAUTH_USER_IDENTITY;
730
+ delete process.env.VAUTH_USER_MAIL;
731
+ assert.equal(await client().connect(), 'fresh-token');
732
+ } finally {
733
+ spawnSync('ssh-add', ['-d', key]);
734
+ delete process.env.VAUTH_USER_IDENTITY;
735
+ delete process.env.VAUTH_USER_MAIL;
736
+ }
737
+ });
738
+ }