@kin-tio/cli 0.6.1 → 0.7.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.
@@ -1,10 +1,11 @@
1
- import { IlinkLoginStore } from './login-store.js';
2
- import { IlinkClient, IlinkProtocolError, normalizeIlinkBaseUrl, } from './protocol/client.js';
3
- import { renderIlinkQrPng } from './qr.js';
1
+ import { IlinkLoginStore, } from './login-store.js';
2
+ import { DEFAULT_ILINK_BASE_URL, IlinkClient, IlinkProtocolError, normalizeIlinkBaseUrl, } from './protocol/client.js';
3
+ import { assertIlinkQrContent } from './qr.js';
4
4
  import { IlinkSecretBox } from './secret-box.js';
5
5
  import { IlinkSqliteStore } from './sqlite-store.js';
6
- import { createIlinkAccountKey } from './store-types.js';
6
+ import { createIlinkAccountKey, } from './store-types.js';
7
7
  const POLL_INTERVAL_MS = 1_000;
8
+ const MAX_EXPIRY_TIMER_MS = 10 * 60 * 1_000;
8
9
  function sleep(milliseconds, signal) {
9
10
  if (signal.aborted)
10
11
  return Promise.reject(signal.reason);
@@ -30,16 +31,18 @@ export class IlinkLoginManager {
30
31
  #client;
31
32
  #logger;
32
33
  #maxAccounts;
34
+ #baseUrl;
33
35
  #clock;
34
36
  #onAccountsChanged;
35
37
  #sleep;
36
38
  #running = new Map();
37
39
  #closed = false;
38
- constructor({ offers, accounts, secretBox, client = new IlinkClient(), maxAccounts = 20, clock = Date.now, onAccountsChanged = () => undefined, logger = console, sleep: sleepFunction = sleep, }) {
40
+ constructor({ offers, accounts, secretBox, client = new IlinkClient(), baseUrl = DEFAULT_ILINK_BASE_URL, maxAccounts = 20, clock = Date.now, onAccountsChanged = () => undefined, logger = console, sleep: sleepFunction = sleep, }) {
39
41
  this.#offers = offers;
40
42
  this.#accounts = accounts;
41
43
  this.#secrets = secretBox;
42
44
  this.#client = client;
45
+ this.#baseUrl = normalizeIlinkBaseUrl(baseUrl);
43
46
  this.#maxAccounts = maxAccounts;
44
47
  this.#clock = clock;
45
48
  this.#onAccountsChanged = onAccountsChanged;
@@ -49,58 +52,88 @@ export class IlinkLoginManager {
49
52
  async start() {
50
53
  if (this.#closed)
51
54
  throw new Error('iLink login manager is closed');
52
- for (const offer of this.#offers.listActive())
53
- this.#startPolling(offer);
55
+ for (const offer of this.#offers.listActive()) {
56
+ if (offer.initiatorKind === 'local_operator') {
57
+ this.#offers.finish(offer.offerId, 'cancelled');
58
+ }
59
+ else
60
+ this.#startPolling(offer);
61
+ }
54
62
  }
55
- async offer(sessionToken) {
63
+ async offer(source, options = {}) {
56
64
  if (this.#closed)
57
65
  throw new Error('iLink login manager is closed');
58
- if (this.#offers.findForSession(sessionToken)) {
66
+ if (this.#offers.find(source)) {
59
67
  throw new Error('An iLink login offer is already pending');
60
68
  }
61
- if (this.#accounts.listActiveAccounts().length +
62
- this.#offers.listActive().length >= this.#maxAccounts) {
69
+ if (source.kind !== 'terminal' &&
70
+ this.#accounts.listActiveAccounts().length +
71
+ this.#offers.listActive().length >= this.#maxAccounts) {
63
72
  throw new Error('iLink account limit reached');
64
73
  }
65
- const localTokens = this.#accounts.listActiveAccountsWithSecrets()
66
- .slice(-10)
67
- .reverse()
74
+ const localAccounts = this.#accounts.listActiveAccountsWithSecrets()
75
+ .slice(source.kind === 'terminal' ? -1 : -10)
76
+ .reverse();
77
+ const localTokens = localAccounts
68
78
  .map(({ account, secret }) => this.#secrets.open(secret.sealedBotToken, {
69
79
  secretKind: 'bot_token',
70
80
  accountId: account.accountKey,
71
81
  peerId: account.ownerPeerId,
72
82
  generation: secret.accountGeneration,
73
83
  }));
74
- const created = await this.#client.createQr({ local_token_list: localTokens });
75
- if (this.#accounts.listActiveAccounts().length +
76
- this.#offers.listActive().length >= this.#maxAccounts) {
84
+ const created = await this.#client.createQr({ local_token_list: localTokens }, options.signal ? { signal: options.signal } : {});
85
+ if (options.signal?.aborted)
86
+ throw options.signal.reason;
87
+ assertIlinkQrContent(created.qrcode_img_content);
88
+ if (source.kind !== 'terminal' &&
89
+ this.#accounts.listActiveAccounts().length +
90
+ this.#offers.listActive().length >= this.#maxAccounts) {
77
91
  throw new Error('iLink account limit reached');
78
92
  }
79
93
  const offer = this.#offers.create({
80
- sessionToken,
94
+ source,
81
95
  qrCode: created.qrcode,
82
- apiBaseUrl: normalizeIlinkBaseUrl('https://ilinkai.weixin.qq.com/'),
96
+ apiBaseUrl: this.#baseUrl,
97
+ candidateAccountKeys: source.kind === 'terminal'
98
+ ? localAccounts.map(({ account }) => account.accountKey)
99
+ : [],
83
100
  });
84
- let png;
85
- try {
86
- png = await renderIlinkQrPng(created.qrcode_img_content);
87
- }
88
- catch (error) {
89
- this.#offers.finish(offer.offerId, 'failed');
90
- throw error;
91
- }
92
101
  this.#startPolling(offer);
93
- return { offerId: offer.offerId, png };
102
+ return {
103
+ offerId: offer.offerId,
104
+ qrContent: created.qrcode_img_content,
105
+ expiresAt: offer.expiresAt,
106
+ };
107
+ }
108
+ status(offerId) {
109
+ const status = this.#offers.status(offerId);
110
+ if (status.status !== 'waiting' && status.status !== 'scanned') {
111
+ this.#running.get(offerId)?.controller.abort();
112
+ }
113
+ return status;
94
114
  }
95
115
  cancel(offerId) {
96
116
  this.#running.get(offerId)?.controller.abort();
97
- this.#offers.finish(offerId, 'cancelled');
117
+ return this.#offers.finish(offerId, 'cancelled');
98
118
  }
99
119
  #startPolling(offer) {
100
120
  if (this.#running.has(offer.offerId) || this.#closed)
101
121
  return;
102
122
  const controller = new AbortController();
123
+ const expiryTimer = setTimeout(() => {
124
+ try {
125
+ this.#offers.finish(offer.offerId, 'expired');
126
+ }
127
+ catch {
128
+ this.#logger.warn?.('[ilink-login] expired offer cleanup failed');
129
+ }
130
+ finally {
131
+ controller.abort(new Error('iLink login offer expired'));
132
+ }
133
+ }, Math.max(0, Math.min(MAX_EXPIRY_TIMER_MS, offer.expiresAt - Number(this.#clock()))));
134
+ expiryTimer.unref();
103
135
  const task = this.#poll(offer, controller.signal).finally(() => {
136
+ clearTimeout(expiryTimer);
104
137
  if (this.#running.get(offer.offerId)?.controller === controller) {
105
138
  this.#running.delete(offer.offerId);
106
139
  }
@@ -125,7 +158,36 @@ export class IlinkLoginManager {
125
158
  return;
126
159
  }
127
160
  if (result.status === 'binded_redirect') {
128
- this.#offers.finish(offer.offerId, 'cancelled');
161
+ if (offer.initiatorKind === 'local_operator') {
162
+ const candidates = offer.candidateAccountKeys;
163
+ let reported;
164
+ try {
165
+ if (result.ilink_bot_id) {
166
+ reported = createIlinkAccountKey(String(result.ilink_bot_id));
167
+ }
168
+ }
169
+ catch {
170
+ reported = undefined;
171
+ }
172
+ const accountKey = reported && candidates.includes(reported)
173
+ ? reported
174
+ : candidates.length === 1
175
+ ? candidates[0]
176
+ : undefined;
177
+ if (!accountKey) {
178
+ this.#offers.finish(offer.offerId, 'failed');
179
+ this.#logger.warn?.('[ilink-login] already-connected account could not be identified');
180
+ return;
181
+ }
182
+ this.#accounts.confirmExistingEnrollment({
183
+ offerId: offer.offerId,
184
+ accountKey,
185
+ now: Number(this.#clock()),
186
+ });
187
+ }
188
+ else {
189
+ this.#offers.finish(offer.offerId, 'already_connected');
190
+ }
129
191
  this.#logger.info?.('[ilink-login] account is already connected');
130
192
  return;
131
193
  }
@@ -138,6 +200,11 @@ export class IlinkLoginManager {
138
200
  else if (result.status === 'scaned') {
139
201
  offer = this.#offers.update(offer.offerId, { status: 'scanned' });
140
202
  }
203
+ else if (result.status === 'need_verifycode' ||
204
+ result.status === 'verify_code_blocked') {
205
+ this.#offers.finish(offer.offerId, 'verification_required');
206
+ return;
207
+ }
141
208
  else if (result.status !== 'wait') {
142
209
  this.#offers.finish(offer.offerId, result.status === 'expired' ? 'expired' : 'failed');
143
210
  return;
@@ -194,5 +261,10 @@ export class IlinkLoginManager {
194
261
  running.controller.abort();
195
262
  await Promise.allSettled([...this.#running.values()].map(({ task }) => task));
196
263
  this.#running.clear();
264
+ for (const offer of this.#offers.listActive()) {
265
+ if (offer.initiatorKind === 'local_operator') {
266
+ this.#offers.finish(offer.offerId, 'cancelled');
267
+ }
268
+ }
197
269
  }
198
270
  }
@@ -1,8 +1,11 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { normalizeIlinkBaseUrl } from './protocol/client.js';
3
3
  import { IlinkSecretBox } from './secret-box.js';
4
- const DEFAULT_TTL_MS = 5 * 60 * 1_000;
4
+ import { assertIlinkAccountKey, } from './store-types.js';
5
+ const ILINK_LOGIN_TTL_MS = 5 * 60 * 1_000;
5
6
  const MAX_TTL_MS = 10 * 60 * 1_000;
7
+ const TERMINAL_ACCOUNT_ID = 'local';
8
+ const TERMINAL_PEER_ID = 'operator';
6
9
  function sha256(value) {
7
10
  return createHash('sha256').update(value).digest('hex');
8
11
  }
@@ -15,9 +18,7 @@ function rowAs(value) {
15
18
  function mapped(row) {
16
19
  return {
17
20
  offerId: row.offer_id,
18
- sourceMessageKey: row.source_message_key,
19
- sourceOpenKfId: row.source_open_kfid,
20
- sourceExternalUserId: row.source_external_userid,
21
+ initiatorKind: row.initiator_kind,
21
22
  apiBaseUrl: row.api_base_url,
22
23
  status: row.status,
23
24
  expiresAt: Number(row.expires_at),
@@ -57,20 +58,52 @@ export class IlinkLoginStore {
57
58
  `).get(offerId));
58
59
  }
59
60
  #runtime(row) {
61
+ const candidates = JSON.parse(row.candidate_account_keys_json);
62
+ if (!Array.isArray(candidates) || candidates.length > 10) {
63
+ throw new Error('Invalid iLink login candidate accounts');
64
+ }
65
+ for (const accountKey of candidates) {
66
+ if (typeof accountKey !== 'string') {
67
+ throw new Error('Invalid iLink login candidate account');
68
+ }
69
+ assertIlinkAccountKey(accountKey);
70
+ }
60
71
  return {
61
72
  ...mapped(row),
73
+ candidateAccountKeys: Object.freeze([...candidates]),
62
74
  qrCode: this.#secrets.open({
63
75
  nonce: row.nonce,
64
76
  ciphertext: row.ciphertext,
65
77
  authTag: row.auth_tag,
66
78
  }, {
67
79
  secretKind: 'qr_token',
68
- accountId: row.source_open_kfid,
69
- peerId: row.source_external_userid,
80
+ accountId: row.source_account_id,
81
+ peerId: row.source_peer_id,
70
82
  generation: row.secret_generation,
71
83
  }),
72
84
  };
73
85
  }
86
+ #source(source) {
87
+ if (source.kind === 'terminal') {
88
+ return {
89
+ initiatorKind: 'local_operator',
90
+ channel: source.kind,
91
+ messageKey: '',
92
+ accountId: TERMINAL_ACCOUNT_ID,
93
+ peerId: TERMINAL_PEER_ID,
94
+ };
95
+ }
96
+ const session = this.#store.getAgentSession(source.sessionToken);
97
+ if (session.channel !== 'wechat_kf')
98
+ throw new Error('Wrong channel for iLink offer');
99
+ return {
100
+ initiatorKind: 'remote_adapter',
101
+ channel: source.kind,
102
+ messageKey: session.messageKey,
103
+ accountId: session.accountKey,
104
+ peerId: session.peerId,
105
+ };
106
+ }
74
107
  #expire(now = Number(this.#clock())) {
75
108
  const expired = this.#database.prepare(`
76
109
  SELECT * FROM ilink_login_offers
@@ -82,43 +115,46 @@ export class IlinkLoginStore {
82
115
  #finishRow(row, result, accountKey, now) {
83
116
  this.#database.prepare(`
84
117
  INSERT INTO ilink_enrollment_audit (
85
- offer_id, source_message_key, source_open_kfid,
86
- source_external_userid, account_key, result, offered_at, completed_at
87
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
118
+ offer_id, initiator_kind, source_channel, source_message_key,
119
+ source_account_id, source_peer_id,
120
+ account_key, result, offered_at, completed_at
121
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
88
122
  ON CONFLICT(offer_id) DO NOTHING
89
- `).run(row.offer_id, row.source_message_key, row.source_open_kfid, row.source_external_userid, accountKey, result, row.created_at, now);
123
+ `).run(row.offer_id, row.initiator_kind, row.source_channel, row.source_message_key, row.source_account_id, row.source_peer_id, accountKey, result, row.created_at, now);
90
124
  this.#database.prepare(`
91
125
  DELETE FROM ilink_login_offers WHERE offer_id = ?
92
126
  `).run(row.offer_id);
93
127
  }
94
- findForSession(sessionToken) {
95
- const session = this.#store.getAgentSession(sessionToken);
96
- if (session.channel !== 'wechat_kf')
97
- throw new Error('Wrong channel for iLink offer');
128
+ find(source) {
129
+ const identity = this.#source(source);
98
130
  return this.#transaction(() => {
99
131
  this.#expire();
100
132
  const row = rowAs(this.#database.prepare(`
101
133
  SELECT * FROM ilink_login_offers
102
- WHERE source_open_kfid = ? AND source_external_userid = ?
134
+ WHERE source_channel = ? AND source_account_id = ? AND source_peer_id = ?
103
135
  AND status IN ('waiting', 'scanned')
104
- `).get(session.accountKey, session.peerId));
136
+ `).get(identity.channel, identity.accountId, identity.peerId));
105
137
  return row ? mapped(row) : undefined;
106
138
  });
107
139
  }
108
- create({ sessionToken, qrCode, apiBaseUrl, ttlMs = DEFAULT_TTL_MS, }) {
109
- const session = this.#store.getAgentSession(sessionToken);
110
- if (session.channel !== 'wechat_kf')
111
- throw new Error('Wrong channel for iLink offer');
140
+ create({ source, qrCode, apiBaseUrl, ttlMs = ILINK_LOGIN_TTL_MS, candidateAccountKeys = [], }) {
141
+ const identity = this.#source(source);
112
142
  if (!qrCode || Buffer.byteLength(qrCode, 'utf8') > 8_192) {
113
143
  throw new Error('Invalid iLink QR token');
114
144
  }
115
145
  const lifetime = Math.max(1_000, Math.min(Number(ttlMs) || 0, MAX_TTL_MS));
116
146
  const offerId = `qo_${randomBytes(20).toString('base64url')}`;
117
147
  const generation = secretGeneration(offerId);
148
+ if (candidateAccountKeys.length > 10) {
149
+ throw new Error('Too many iLink login candidate accounts');
150
+ }
151
+ for (const accountKey of candidateAccountKeys)
152
+ assertIlinkAccountKey(accountKey);
153
+ const candidates = JSON.stringify([...new Set(candidateAccountKeys)]);
118
154
  const sealed = this.#secrets.seal(qrCode, {
119
155
  secretKind: 'qr_token',
120
- accountId: session.accountKey,
121
- peerId: session.peerId,
156
+ accountId: identity.accountId,
157
+ peerId: identity.peerId,
122
158
  generation,
123
159
  });
124
160
  const now = Number(this.#clock());
@@ -126,19 +162,20 @@ export class IlinkLoginStore {
126
162
  this.#expire(now);
127
163
  const existing = this.#database.prepare(`
128
164
  SELECT 1 FROM ilink_login_offers
129
- WHERE source_open_kfid = ? AND source_external_userid = ?
165
+ WHERE source_channel = ? AND source_account_id = ? AND source_peer_id = ?
130
166
  AND status IN ('waiting', 'scanned')
131
- `).get(session.accountKey, session.peerId);
167
+ `).get(identity.channel, identity.accountId, identity.peerId);
132
168
  if (existing)
133
169
  throw new Error('An iLink login offer is already pending');
134
170
  this.#database.prepare(`
135
171
  INSERT INTO ilink_login_offers (
136
- offer_id, source_message_key, source_open_kfid,
137
- source_external_userid, secret_generation,
138
- nonce, ciphertext, auth_tag, api_base_url, status,
172
+ offer_id, initiator_kind, source_channel, source_message_key,
173
+ source_account_id, source_peer_id, secret_generation,
174
+ candidate_account_keys_json, nonce, ciphertext, auth_tag,
175
+ api_base_url, status,
139
176
  expires_at, last_polled_at, created_at, updated_at
140
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'waiting', ?, 0, ?, ?)
141
- `).run(offerId, session.messageKey, session.accountKey, session.peerId, generation, sealed.nonce, sealed.ciphertext, sealed.authTag, normalizeIlinkBaseUrl(apiBaseUrl), now + lifetime, now, now);
177
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'waiting', ?, 0, ?, ?)
178
+ `).run(offerId, identity.initiatorKind, identity.channel, identity.messageKey, identity.accountId, identity.peerId, generation, candidates, sealed.nonce, sealed.ciphertext, sealed.authTag, normalizeIlinkBaseUrl(apiBaseUrl), now + lifetime, now, now);
142
179
  return this.#runtime(this.#row(offerId));
143
180
  });
144
181
  }
@@ -158,6 +195,18 @@ export class IlinkLoginStore {
158
195
  AND expires_at > ?
159
196
  `).get(offerId, Number(this.#clock())));
160
197
  }
198
+ status(offerId) {
199
+ return this.#transaction(() => {
200
+ this.#expire();
201
+ const active = this.#row(offerId);
202
+ if (active)
203
+ return { status: active.status };
204
+ const audit = rowAs(this.#database.prepare(`
205
+ SELECT result FROM ilink_enrollment_audit WHERE offer_id = ?
206
+ `).get(offerId));
207
+ return { status: audit?.result || 'unknown' };
208
+ });
209
+ }
161
210
  update(offerId, input) {
162
211
  const now = Number(this.#clock());
163
212
  return this.#transaction(() => {
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { PNG } from 'pngjs';
5
- import { create } from 'qrcode';
5
+ import { create, toBuffer } from 'qrcode';
6
6
  const MAX_QR_CONTENT_BYTES = 2_048;
7
7
  const MAX_QR_PNG_BYTES = 512 * 1_024;
8
8
  const CARD_WIDTH = 720;
@@ -11,10 +11,13 @@ const QR_REGION_X = 120;
11
11
  const QR_REGION_Y = 220;
12
12
  const QR_REGION_SIZE = 480;
13
13
  const QUIET_ZONE_MODULES = 4;
14
+ const RAW_QR_SCALE = 6;
14
15
  const QR_DARK = Object.freeze([0x11, 0x18, 0x14, 0xff]);
15
16
  const PNG_SIGNATURE = Buffer.from([
16
17
  0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
17
18
  ]);
19
+ const TERMINAL_INK = '\u001b[47m\u001b[30m';
20
+ const TERMINAL_RESET = '\u001b[0m';
18
21
  function templatePath() {
19
22
  const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
20
23
  const sourcePath = path.resolve(moduleDirectory, '../../assets/ilink-login-card.png');
@@ -42,7 +45,7 @@ export class IlinkQrRenderError extends Error {
42
45
  this.code = code;
43
46
  }
44
47
  }
45
- function validateContent(content) {
48
+ export function assertIlinkQrContent(content) {
46
49
  if (typeof content !== 'string' ||
47
50
  content.trim().length === 0 ||
48
51
  Buffer.byteLength(content, 'utf8') > MAX_QR_CONTENT_BYTES) {
@@ -91,7 +94,7 @@ function renderCard(content) {
91
94
  });
92
95
  }
93
96
  export async function renderIlinkQrPng(content) {
94
- validateContent(content);
97
+ assertIlinkQrContent(content);
95
98
  let png;
96
99
  try {
97
100
  png = renderCard(content);
@@ -107,3 +110,64 @@ export async function renderIlinkQrPng(content) {
107
110
  }
108
111
  return png;
109
112
  }
113
+ export async function renderIlinkRawQrPng(content) {
114
+ assertIlinkQrContent(content);
115
+ let png;
116
+ try {
117
+ png = await toBuffer(content, {
118
+ errorCorrectionLevel: 'M',
119
+ margin: QUIET_ZONE_MODULES,
120
+ scale: RAW_QR_SCALE,
121
+ type: 'png',
122
+ color: {
123
+ dark: '#111814ff',
124
+ light: '#ffffffff',
125
+ },
126
+ });
127
+ }
128
+ catch {
129
+ throw new IlinkQrRenderError('qr_render_failed', 'Unable to render iLink QR image');
130
+ }
131
+ if (png.length < PNG_SIGNATURE.length || !png.subarray(0, 8).equals(PNG_SIGNATURE)) {
132
+ throw new IlinkQrRenderError('invalid_qr_png', 'Rendered iLink QR image is invalid');
133
+ }
134
+ if (png.length > MAX_QR_PNG_BYTES) {
135
+ throw new IlinkQrRenderError('qr_png_too_large', 'Rendered iLink QR image is too large');
136
+ }
137
+ return png;
138
+ }
139
+ export function renderIlinkQrTerminal(content) {
140
+ assertIlinkQrContent(content);
141
+ let qr;
142
+ try {
143
+ qr = create(content, { errorCorrectionLevel: 'M' });
144
+ }
145
+ catch {
146
+ throw new IlinkQrRenderError('qr_render_failed', 'Unable to render iLink QR code');
147
+ }
148
+ const matrixSize = qr.modules.size;
149
+ const columns = matrixSize + QUIET_ZONE_MODULES * 2;
150
+ const rows = Math.ceil(columns / 2);
151
+ const dark = (row, column) => {
152
+ const matrixRow = row - QUIET_ZONE_MODULES;
153
+ const matrixColumn = column - QUIET_ZONE_MODULES;
154
+ return (matrixRow >= 0 && matrixRow < matrixSize &&
155
+ matrixColumn >= 0 && matrixColumn < matrixSize &&
156
+ Boolean(qr.modules.get(matrixRow, matrixColumn)));
157
+ };
158
+ const lines = [];
159
+ for (let row = 0; row < columns; row += 2) {
160
+ let line = TERMINAL_INK;
161
+ for (let column = 0; column < columns; column += 1) {
162
+ const top = dark(row, column);
163
+ const bottom = dark(row + 1, column);
164
+ line += top ? (bottom ? '█' : '▀') : (bottom ? '▄' : ' ');
165
+ }
166
+ lines.push(`${line}${TERMINAL_RESET}`);
167
+ }
168
+ return Object.freeze({
169
+ text: `${lines.join('\n')}\n`,
170
+ columns,
171
+ rows,
172
+ });
173
+ }
@@ -1,4 +1,7 @@
1
1
  import { createCipheriv, createDecipheriv, createSecretKey, randomBytes, } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { assertTrustedDirectory, ensurePrivateDirectory, } from '../lib/private-directory.js';
2
5
  const KEY_BYTES = 32;
3
6
  const NONCE_BYTES = 12;
4
7
  const AUTH_TAG_BYTES = 16;
@@ -27,6 +30,76 @@ function decodeConfiguredKey(configuredKey) {
27
30
  }
28
31
  return decoded;
29
32
  }
33
+ export function readOrCreateIlinkStorageKey(filePath, { allowCreate }) {
34
+ const target = path.resolve(filePath);
35
+ const parent = ensurePrivateDirectory(path.dirname(target));
36
+ assertTrustedDirectory(parent, 'iLink storage key directory', true);
37
+ try {
38
+ const stat = fs.lstatSync(target);
39
+ if (!stat.isFile() || stat.isSymbolicLink()) {
40
+ throw new Error(`iLink storage key is not a regular file: ${target}`);
41
+ }
42
+ if (process.platform !== 'win32') {
43
+ const uid = process.getuid?.();
44
+ if ((uid !== undefined && stat.uid !== uid) || (stat.mode & 0o077) !== 0) {
45
+ throw new Error(`iLink storage key has unsafe permissions: ${target}`);
46
+ }
47
+ }
48
+ const configuredKey = fs.readFileSync(target, 'utf8').trim();
49
+ const decoded = decodeConfiguredKey(configuredKey);
50
+ decoded.fill(0);
51
+ return configuredKey;
52
+ }
53
+ catch (error) {
54
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
55
+ throw error;
56
+ }
57
+ }
58
+ if (!allowCreate) {
59
+ throw new Error('iLink storage key is missing for existing encrypted state');
60
+ }
61
+ const configuredKey = randomBytes(KEY_BYTES).toString('base64url');
62
+ let descriptor;
63
+ let created = false;
64
+ try {
65
+ descriptor = fs.openSync(target, 'wx', 0o600);
66
+ created = true;
67
+ fs.writeFileSync(descriptor, `${configuredKey}\n`, 'utf8');
68
+ fs.fsyncSync(descriptor);
69
+ fs.closeSync(descriptor);
70
+ descriptor = undefined;
71
+ if (process.platform !== 'win32') {
72
+ const parentDescriptor = fs.openSync(parent, 'r');
73
+ try {
74
+ fs.fsyncSync(parentDescriptor);
75
+ }
76
+ finally {
77
+ fs.closeSync(parentDescriptor);
78
+ }
79
+ }
80
+ return configuredKey;
81
+ }
82
+ catch (error) {
83
+ if (descriptor !== undefined) {
84
+ try {
85
+ fs.closeSync(descriptor);
86
+ }
87
+ catch { }
88
+ }
89
+ if (created) {
90
+ try {
91
+ fs.unlinkSync(target);
92
+ }
93
+ catch (cleanupError) {
94
+ throw new AggregateError([error, cleanupError], 'iLink storage key creation and cleanup both failed');
95
+ }
96
+ }
97
+ if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
98
+ return readOrCreateIlinkStorageKey(target, { allowCreate });
99
+ }
100
+ throw error;
101
+ }
102
+ }
30
103
  function boundedScopeText(value) {
31
104
  return typeof value === 'string' &&
32
105
  value.length > 0 &&