@johnhenry/wsh 0.17.0 → 0.18.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.
@@ -3,38 +3,36 @@
3
3
  *
4
4
  * Uploads and downloads delegate to the underlying client's upload()/
5
5
  * download() (FileChunk control messages over a 'file'-kind channel —
6
- * see client.mjs). list() is a separate helper that runs `ls -la` over
7
- * a plain exec channel.
6
+ * see client.mjs). list() (wsh #59) delegates to the client's fileList(),
7
+ * which sends a structured FileOp{op:"list"} and reads the typed
8
+ * FileResult.entries -- the same wire path upload/download already use
9
+ * for moving bytes, now extended to directory listings.
10
+ *
11
+ * Before wsh #59, list() ran `ls -la` over a plain exec channel and
12
+ * parsed the text output client-side -- a private, JS-only convention
13
+ * the Rust client had no equivalent of at all (see wsh #58/#59, and the
14
+ * FileEntry nested type in spec/wsh-v1.yaml this now shares with Rust).
8
15
  */
9
16
 
10
- import { MSG, open, CHANNEL_KIND } from './messages.mjs';
11
- import { waitForControlMessage } from './control-listener.mjs';
12
-
13
17
  /** Default timeout for waiting on control messages (30 seconds). */
14
18
  const RESPONSE_TIMEOUT_MS = 30_000;
15
19
 
16
20
  export class WshFileTransfer {
17
21
  /**
18
22
  * @type {{
19
- * upload: function, download: function,
20
- * sendControl: function, openStream: function, onControl: function,
23
+ * upload: function, download: function, fileList: function,
21
24
  * }}
22
25
  */
23
26
  #client;
24
27
 
25
28
  /**
26
- * @param {object} client - A WshClient (or any object exposing upload()/
27
- * download() with the same signatures — those are used directly for
28
- * upload/download). list() additionally needs, for the underlying exec
29
- * channel it runs `ls -la` over:
30
- * - sendControl(msg): send a control message
31
- * - openStream(): open a new bidirectional stream
32
- * - onControl: settable callback for incoming control messages
33
- * (or a method to add a listener — see _waitForMessage)
34
- *
35
- * Only `list()` needs the control-channel members, so they are checked
36
- * there rather than here: uploading and downloading through a client
37
- * that has `upload()`/`download()` and nothing else stays valid.
29
+ * @param {object} client - A WshClient (or any object exposing the same
30
+ * methods used below). `upload()`/`download()` are used directly for
31
+ * file transfer; `list()` uses `fileList()` (the structured
32
+ * FileOp/FileResult request-response helper already on WshClient).
33
+ * Each method checks for the members it specifically needs, so a
34
+ * client exposing only `upload()`/`download()` (and nothing else)
35
+ * stays valid for those two calls.
38
36
  */
39
37
  constructor(client) {
40
38
  if (!client) throw new Error('WshFileTransfer requires a client');
@@ -97,156 +95,47 @@ export class WshFileTransfer {
97
95
  }
98
96
 
99
97
  /**
100
- * List files at a remote path by executing `ls -la` via the client.
98
+ * List files at a remote path over the structured file channel (wsh #59):
99
+ * sends `FileOp{op:"list"}` via the client's `fileList()` and reads the
100
+ * typed `FileResult.entries` -- the same `FileEntry` shape
101
+ * `crates/wsh-core`'s Rust client is generated from, so a symlink can't
102
+ * be reported as a plain file here and correctly here-and-there.
101
103
  *
102
- * This uses a standard exec channel rather than the file transfer protocol,
103
- * parsing the output of `ls -la` into structured entries.
104
+ * A refusal (unimplemented op, unauthorized fs capability, no such
105
+ * directory) throws -- it is never coerced into an empty array, so a
106
+ * caller can't mistake "refused" for "empty directory" (wsh #58).
104
107
  *
105
108
  * @param {string} remotePath - Directory path on the remote host
106
- * @returns {Promise<Array<{ name: string, size: number, modified: string, type: string }>>}
109
+ * @param {object} [opts]
110
+ * @param {number} [opts.timeout] - Timeout in ms for the server response
111
+ * @returns {Promise<Array<{
112
+ * name: string, size: number, modified: Date,
113
+ * type: 'file'|'directory'|'symlink'|'device'|'pipe'|'socket',
114
+ * symlinkTarget?: string,
115
+ * }>>}
107
116
  */
108
- async list(remotePath) {
117
+ async list(remotePath, { timeout = RESPONSE_TIMEOUT_MS } = {}) {
109
118
  if (!remotePath || typeof remotePath !== 'string') {
110
119
  throw new Error('remotePath is required');
111
120
  }
112
- if (typeof this.#client.sendControl !== 'function' || typeof this.#client.openStream !== 'function') {
121
+ if (typeof this.#client.fileList !== 'function') {
113
122
  throw new TypeError(
114
- 'WshFileTransfer.list() requires a client exposing sendControl() and openStream() (e.g. WshClient)'
123
+ 'WshFileTransfer.list() requires a client exposing fileList() (e.g. WshClient)'
115
124
  );
116
125
  }
117
126
 
118
- // Use an exec channel to run ls
119
- const openMsg = open({
120
- kind: CHANNEL_KIND.EXEC,
121
- command: `ls -la ${this._shellEscape(remotePath)}`,
122
- });
123
-
124
- await this.#client.sendControl(openMsg);
125
-
126
- const response = await this._waitForMessage(
127
- (msg) => msg.type === MSG.OPEN_OK || msg.type === MSG.OPEN_FAIL,
128
- RESPONSE_TIMEOUT_MS
129
- );
130
-
131
- if (response.type === MSG.OPEN_FAIL) {
132
- throw new Error(`List failed: ${response.reason || 'unknown reason'}`);
133
- }
134
-
135
- // Read the output stream
136
- const stream = await this.#client.openStream();
137
- const reader = stream.readable.getReader();
138
- const chunks = [];
139
-
140
- try {
141
- while (true) {
142
- const { done, value } = await reader.read();
143
- if (done) break;
144
- chunks.push(value);
145
- }
146
- } finally {
147
- reader.releaseLock();
148
- }
149
-
150
- const output = new TextDecoder().decode(this._concatChunks(chunks));
151
- return this._parseLsOutput(output);
152
- }
153
-
154
- // ── Internal helpers ──────────────────────────────────────────────
155
-
156
- /**
157
- * Wait for a control message matching a predicate.
158
- *
159
- * Temporarily hooks into the client's control message flow and resolves
160
- * when a matching message arrives (or rejects on timeout).
161
- *
162
- * @param {function(object): boolean} predicate
163
- * @param {number} timeoutMs
164
- * @returns {Promise<object>}
165
- */
166
- _waitForMessage(predicate, timeoutMs) {
167
- return waitForControlMessage(this.#client, predicate, timeoutMs, 'File transfer response');
168
- }
169
-
170
- /**
171
- * Concatenate an array of Uint8Arrays into one.
172
- *
173
- * @param {Uint8Array[]} chunks
174
- * @param {number} [totalLength] - Pre-computed total, avoids re-summing
175
- * @returns {Uint8Array}
176
- */
177
- _concatChunks(chunks, totalLength) {
178
- if (chunks.length === 0) return new Uint8Array(0);
179
- if (chunks.length === 1) return chunks[0];
180
-
181
- const total = totalLength ?? chunks.reduce((sum, c) => sum + c.length, 0);
182
- const result = new Uint8Array(total);
183
- let offset = 0;
184
- for (const chunk of chunks) {
185
- result.set(chunk, offset);
186
- offset += chunk.length;
187
- }
188
- return result;
189
- }
190
-
191
- /**
192
- * Parse the output of `ls -la` into structured entries.
193
- *
194
- * Expected format per line:
195
- * drwxr-xr-x 2 user group 4096 Jan 15 12:00 dirname
196
- * -rw-r--r-- 1 user group 12345 Jan 15 12:00 filename.txt
197
- *
198
- * @param {string} output
199
- * @returns {Array<{ name: string, size: number, modified: string, type: string }>}
200
- */
201
- _parseLsOutput(output) {
202
- const lines = output.split('\n').filter((l) => l.trim());
203
- const entries = [];
204
-
205
- for (const line of lines) {
206
- // Skip the "total" header line
207
- if (line.startsWith('total ')) continue;
208
-
209
- // ls -la columns: perms links owner group size month day time name
210
- const parts = line.split(/\s+/);
211
- if (parts.length < 9) continue;
212
-
213
- const perms = parts[0];
214
- const size = parseInt(parts[4], 10) || 0;
215
- const month = parts[5];
216
- const day = parts[6];
217
- const time = parts[7];
218
- // Name may contain spaces — rejoin everything from column 8 onward
219
- const name = parts.slice(8).join(' ');
220
-
221
- // Skip . and .. entries
222
- if (name === '.' || name === '..') continue;
127
+ const result = await this.#client.fileList(remotePath, timeout);
223
128
 
224
- // Determine type from permission string first character
225
- let type = 'file';
226
- if (perms.startsWith('d')) type = 'directory';
227
- else if (perms.startsWith('l')) type = 'symlink';
228
- else if (perms.startsWith('c') || perms.startsWith('b')) type = 'device';
229
- else if (perms.startsWith('p')) type = 'pipe';
230
- else if (perms.startsWith('s')) type = 'socket';
231
-
232
- entries.push({
233
- name,
234
- size,
235
- modified: `${month} ${day} ${time}`,
236
- type,
237
- });
129
+ if (!result.success) {
130
+ throw new Error(result.error_message || `List failed: refused for ${remotePath}`);
238
131
  }
239
132
 
240
- return entries;
241
- }
242
-
243
- /**
244
- * Basic shell escaping for a path argument.
245
- * @param {string} str
246
- * @returns {string}
247
- */
248
- _shellEscape(str) {
249
- // Wrap in single quotes, escaping any embedded single quotes
250
- return "'" + str.replace(/'/g, "'\\''") + "'";
133
+ return (result.entries || []).map((entry) => ({
134
+ name: entry.name,
135
+ size: entry.size,
136
+ modified: new Date(entry.modified * 1000),
137
+ type: entry.type,
138
+ ...(entry.symlink_target !== undefined ? { symlinkTarget: entry.symlink_target } : {}),
139
+ }));
251
140
  }
252
141
  }
package/src/index.d.ts CHANGED
@@ -238,6 +238,13 @@ export const PROTOCOL_VERSION: 'wsh-v1';
238
238
  */
239
239
  export const MCP_CALL_ID_FEATURE: 'mcp-call-id';
240
240
 
241
+ /**
242
+ * ServerHello feature: the host discovers a client-opened exec data stream
243
+ * without help (the transport announces it), so the client writes no
244
+ * one-byte primer against it.
245
+ */
246
+ export const STREAM_ANNOUNCE_FEATURE: 'stream-announce';
247
+
241
248
  /** A wsh protocol control message (all messages have a numeric `type`). */
242
249
  export interface WshMessage {
243
250
  type: number;
@@ -778,6 +785,14 @@ export function fileResult(opts?: {
778
785
  channelId?: number;
779
786
  success?: boolean;
780
787
  metadata?: Record<string, unknown>;
788
+ /** wsh #59: typed directory entries for a "list" op (see WshFileListEntry-shaped wire fields). */
789
+ entries?: Array<{
790
+ name: string;
791
+ size: number;
792
+ modified: number;
793
+ type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
794
+ symlink_target?: string;
795
+ }>;
781
796
  errorMessage?: string;
782
797
  }): WshMessage;
783
798
 
@@ -789,6 +804,18 @@ export function fileChunk(opts?: {
789
804
  totalSize?: number;
790
805
  }): WshMessage;
791
806
 
807
+ /** wsh #59: install a public key into the remote's authorized_keys (see WshClient.addAuthorizedKey). */
808
+ export function authorizedKeyAdd(opts?: {
809
+ publicKey?: Uint8Array;
810
+ comment?: string;
811
+ }): WshMessage;
812
+
813
+ export function authorizedKeyResult(opts?: {
814
+ success?: boolean;
815
+ added?: boolean;
816
+ errorMessage?: string;
817
+ }): WshMessage;
818
+
792
819
  export function policyEval(opts?: {
793
820
  requestId?: string;
794
821
  action?: string;
@@ -875,15 +902,19 @@ export function exportPrivateKeyPKCS8(privateKey: CryptoKey): Promise<Uint8Array
875
902
  export function importPrivateKeyPKCS8(pkcs8: Uint8Array, extractable?: boolean): Promise<CryptoKey>;
876
903
 
877
904
  /**
878
- * Sign data with an Ed25519 private key.
905
+ * Sign data with an Ed25519 private key. Positional order mirrors WebCrypto
906
+ * (`sign(privateKey, message)`) and is the canonical form across wsh, raijin
907
+ * and browsermesh.
879
908
  * @returns 64-byte signature
880
909
  */
881
- export function sign(privateKey: CryptoKey, data: Uint8Array): Promise<Uint8Array>;
910
+ export function sign(privateKey: CryptoKey, message: Uint8Array): Promise<Uint8Array>;
882
911
 
883
912
  /**
884
- * Verify an Ed25519 signature.
913
+ * Verify an Ed25519 signature. Positional order mirrors WebCrypto
914
+ * (`verify(publicKey, signature, message)`) and is the canonical form across
915
+ * wsh, raijin and browsermesh.
885
916
  */
886
- export function verify(publicKey: CryptoKey, signature: Uint8Array, data: Uint8Array): Promise<boolean>;
917
+ export function verify(publicKey: CryptoKey, signature: Uint8Array, message: Uint8Array): Promise<boolean>;
887
918
 
888
919
  /** Options binding extra context into the auth transcript. */
889
920
  export interface WshTranscriptOptions {
@@ -1541,6 +1572,13 @@ export interface WshOpenSessionOptions {
1541
1572
  rows?: number;
1542
1573
  env?: Record<string, string>;
1543
1574
  timeout?: number;
1575
+ /**
1576
+ * Stream-mode `exec` only (default `true`): write the one-byte "primer"
1577
+ * that older hosts need to discover the client-opened data stream. Skipped
1578
+ * automatically against a host advertising `stream-announce`; `false`
1579
+ * never sends it.
1580
+ */
1581
+ primer?: boolean;
1544
1582
  }
1545
1583
 
1546
1584
  /** Options for WshClient.attachSession(). */
@@ -1591,6 +1629,8 @@ export interface WshExecOptions {
1591
1629
  keyPair?: CryptoKeyPair;
1592
1630
  password?: string;
1593
1631
  timeout?: number;
1632
+ /** See {@link WshOpenSessionOptions.primer}. */
1633
+ primer?: boolean;
1594
1634
  }
1595
1635
 
1596
1636
  /** Result from WshClient.exec(). */
@@ -1649,6 +1689,28 @@ export interface WshE2EResult {
1649
1689
  hybrid: boolean;
1650
1690
  }
1651
1691
 
1692
+ /**
1693
+ * Raw `FileResult` response to a structured file-channel operation
1694
+ * (`WshClient.fileList`/`fileRemove`/etc.), as received off the wire --
1695
+ * wire field names (snake_case), not the camelCase shape the `fileResult()`
1696
+ * message constructor takes as input. `entries` is only populated for a
1697
+ * `"list"` op; `error_message` is only present on failure.
1698
+ */
1699
+ export interface WshFileOperationResult {
1700
+ type: number;
1701
+ channel_id: number;
1702
+ success: boolean;
1703
+ metadata: Record<string, unknown>;
1704
+ entries: Array<{
1705
+ name: string;
1706
+ size: number;
1707
+ modified: number;
1708
+ type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
1709
+ symlink_target?: string;
1710
+ }>;
1711
+ error_message?: string;
1712
+ }
1713
+
1652
1714
  /**
1653
1715
  * WshClient -- manages a wsh connection, authentication, and multiple sessions.
1654
1716
  *
@@ -1764,6 +1826,36 @@ export class WshClient {
1764
1826
  */
1765
1827
  revokeSessionAccess(sessionId: string, principal: string, reason?: string): Promise<void>;
1766
1828
 
1829
+ /**
1830
+ * List a remote directory over the structured file channel (`FileOp`
1831
+ * op `"list"`). Prefer `WshFileTransfer.list()` for a typed
1832
+ * `WshFileListEntry[]` result (with `modified` as a `Date`) -- this is
1833
+ * the raw wire-shaped `FileResult` response `fileOperation()` waits on.
1834
+ */
1835
+ fileList(path: string, timeout?: number): Promise<WshFileOperationResult>;
1836
+
1837
+ /**
1838
+ * Remove a remote file or directory (`FileOp` op `"remove"`). Refused
1839
+ * today by every `wsh-server` release ("not yet implemented" -- see
1840
+ * README's file-op parity table); wired here so clients built against
1841
+ * this SDK don't need to wait on the server to type-check against it.
1842
+ */
1843
+ fileRemove(path: string, timeout?: number): Promise<WshFileOperationResult>;
1844
+
1845
+ /**
1846
+ * Install a raw 32-byte Ed25519 public key into the remote host's
1847
+ * `~/.wsh/authorized_keys` (wsh #59's `AuthorizedKeyAdd` protocol
1848
+ * message -- replaces `wsh copy-id`'s previous CLI-only shell-command
1849
+ * approach with something every implementation, including this one,
1850
+ * can call directly). Idempotent: installing an already-present key
1851
+ * resolves `added: false` rather than duplicating the line or erroring.
1852
+ */
1853
+ addAuthorizedKey(
1854
+ publicKeyRaw: Uint8Array,
1855
+ comment?: string,
1856
+ timeout?: number,
1857
+ ): Promise<{ added: boolean }>;
1858
+
1767
1859
  /**
1768
1860
  * Gracefully disconnect: close all sessions and the transport.
1769
1861
  */
@@ -1993,6 +2085,48 @@ export class WshKeyStore {
1993
2085
  restore(passphrase: string): Promise<WshRestoreResult>;
1994
2086
  }
1995
2087
 
2088
+ // ============================================================================
2089
+ // known-hosts.mjs -- WshKnownHosts (wsh #59)
2090
+ // ============================================================================
2091
+
2092
+ /** Minimal storage interface `WshKnownHosts` needs -- `localStorage` already satisfies it. */
2093
+ export interface WshKnownHostsStorage {
2094
+ getItem(key: string): string | null;
2095
+ setItem(key: string, value: string): void;
2096
+ removeItem(key: string): void;
2097
+ }
2098
+
2099
+ /** Result of verifying a host's fingerprint against the stored record. */
2100
+ export interface WshHostVerifyResult {
2101
+ status: 'known' | 'unknown' | 'changed';
2102
+ /** Previously stored fingerprint, present only when status === 'changed'. */
2103
+ expected?: string;
2104
+ }
2105
+
2106
+ /**
2107
+ * WshKnownHosts -- trust-on-first-use (TOFU) host-identity store, mirroring
2108
+ * Rust's `KnownHosts`/`HostStatus` (`crates/wsh-client/src/known_hosts.rs`).
2109
+ * Pins `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, wsh #59) -- no
2110
+ * `wsh-server` release populates that field yet, so this has nothing
2111
+ * authoritative to verify against for any real server today. See the
2112
+ * class doc comment in `known-hosts.mjs` for the full reasoning.
2113
+ */
2114
+ export class WshKnownHosts {
2115
+ constructor(opts?: { storage?: WshKnownHostsStorage; storageKey?: string });
2116
+
2117
+ /** Verify a host's fingerprint against the stored record. */
2118
+ verifyHost(host: string, fingerprint: string): WshHostVerifyResult;
2119
+
2120
+ /** Add or update a host's fingerprint. */
2121
+ addHost(host: string, fingerprint: string): void;
2122
+
2123
+ /** Remove a host entry. Returns true if an entry was removed. */
2124
+ removeHost(host: string): boolean;
2125
+
2126
+ /** List all known hosts and their fingerprints. */
2127
+ list(): Array<{ host: string; fingerprint: string }>;
2128
+ }
2129
+
1996
2130
  // ============================================================================
1997
2131
  // file-transfer.mjs -- WshFileTransfer
1998
2132
  // ============================================================================
@@ -2015,12 +2149,21 @@ export interface WshFileUploadResult {
2015
2149
  bytesTransferred: number;
2016
2150
  }
2017
2151
 
2018
- /** File listing entry. */
2152
+ /**
2153
+ * File listing entry (wsh #59: generated from spec/wsh-v1.yaml's
2154
+ * `FileEntry` nested type -- the same shape `crates/wsh-core`'s Rust
2155
+ * `FileEntry` struct is generated from, so a directory entry can no longer
2156
+ * describe a symlink as a plain file in one implementation and not the
2157
+ * other). `modified` is converted from the wire's Unix epoch seconds into
2158
+ * a `Date` for JS ergonomics. `symlinkTarget` is only present when
2159
+ * `type === 'symlink'`.
2160
+ */
2019
2161
  export interface WshFileListEntry {
2020
2162
  name: string;
2021
2163
  size: number;
2022
- modified: string;
2164
+ modified: Date;
2023
2165
  type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
2166
+ symlinkTarget?: string;
2024
2167
  }
2025
2168
 
2026
2169
  /**
package/src/index.mjs CHANGED
@@ -35,6 +35,7 @@ export {
35
35
  nodeAnnounce, nodeRedirect,
36
36
  sessionGrant, sessionRevoke,
37
37
  fileOp, fileResult, fileChunk,
38
+ authorizedKeyAdd, authorizedKeyResult,
38
39
  policyEval, policyResult, policyUpdate,
39
40
  terminalConfig,
40
41
  msgName, isValidMessage,
@@ -72,11 +73,14 @@ export {
72
73
  // Session + Client
73
74
  export { WshSession } from './session.mjs';
74
75
  export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
75
- export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
76
+ export { WshClient, MCP_CALL_ID_FEATURE, STREAM_ANNOUNCE_FEATURE } from './client.mjs';
76
77
 
77
78
  // Key storage
78
79
  export { WshKeyStore } from './keystore.mjs';
79
80
 
81
+ // Host identity (TOFU) -- wsh #59
82
+ export { WshKnownHosts } from './known-hosts.mjs';
83
+
80
84
  // File transfer
81
85
  export { WshFileTransfer } from './file-transfer.mjs';
82
86
 
@@ -0,0 +1,139 @@
1
+ /**
2
+ * WshKnownHosts — trust-on-first-use (TOFU) host-identity store for the
3
+ * browser SDK (wsh #59).
4
+ *
5
+ * ## Why this exists, and why it is not yet load-bearing
6
+ *
7
+ * wsh #59 asked for a decision on host identity: TOFU *policy* (when to
8
+ * prompt, how long to trust) is legitimately per-implementation, but the
9
+ * *host identity record* -- what a fingerprint covers, how it is computed,
10
+ * what counts as "the same host" -- must not be invented twice. This class
11
+ * mirrors Rust's `KnownHosts`/`HostStatus`
12
+ * (`crates/wsh-client/src/known_hosts.rs`) in shape and semantics --
13
+ * `verifyHost`/`addHost`/`removeHost`/`list`, keyed by a host label,
14
+ * storing one fingerprint per host -- so the browser gets the same
15
+ * mechanism rather than a second, differently-shaped answer arrived at
16
+ * later under pressure.
17
+ *
18
+ * What it pins is `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, added
19
+ * by this same change): the server's own persistent Ed25519 host-identity
20
+ * key, SHA-256-fingerprinted the same way `fingerprint()` in `auth.mjs`
21
+ * already fingerprints user/peer keys. That field is new spec surface,
22
+ * not new server behavior -- no `wsh-server` release populates it yet.
23
+ * Minting and persisting a server host keypair is a separate,
24
+ * security-sensitive feature this change does not implement (see the
25
+ * wsh #59 PR description for the full reasoning). Until a server
26
+ * populates it, `verifyHost()` has nothing authoritative to check for any
27
+ * real deployment -- exactly the same degraded state Rust's own
28
+ * `verify_host_key` is in today when it falls back to `fingerprints[0]`
29
+ * (an authorized *client* key, not a host identity -- see that fallback's
30
+ * comment in `client.rs`). This store does not paper over that; a caller
31
+ * that gets `'unknown'` back from every real server today is seeing the
32
+ * honest state of host verification in this protocol version, not a bug
33
+ * in this class.
34
+ *
35
+ * The README's `serverCertificateHashes` pinning
36
+ * (see "Pinning a Self-Signed Certificate") is a different mechanism: it
37
+ * pins a certificate supplied *per connection*, decided by the caller in
38
+ * advance -- it is not a persisted record of what a host presented last
39
+ * time, so it does not already solve TOFU. `WshKnownHosts` is the
40
+ * missing persisted-record half.
41
+ *
42
+ * ## Storage
43
+ *
44
+ * Pluggable (defaults to `localStorage` when available, else an
45
+ * in-memory `Map` that does not survive a page reload) since "persist
46
+ * trust across sessions" needs a browser storage API this package has
47
+ * never assumed before. Pass `{ storage }` with a `getItem`/`setItem`/
48
+ * `removeItem` interface (the `Storage` interface `localStorage`
49
+ * already implements) to use IndexedDB-via-adapter, `sessionStorage`,
50
+ * or anything else.
51
+ */
52
+
53
+ const STORAGE_KEY = 'wsh:known-hosts:v1';
54
+
55
+ /** In-memory fallback storage, used when no persistent storage is available or injected. */
56
+ class MemoryStorage {
57
+ #map = new Map();
58
+ getItem(key) { return this.#map.has(key) ? this.#map.get(key) : null; }
59
+ setItem(key, value) { this.#map.set(key, value); }
60
+ removeItem(key) { this.#map.delete(key); }
61
+ }
62
+
63
+ function defaultStorage() {
64
+ if (typeof localStorage !== 'undefined' && localStorage) return localStorage;
65
+ return new MemoryStorage();
66
+ }
67
+
68
+ export class WshKnownHosts {
69
+ #storage;
70
+ #storageKey;
71
+
72
+ /**
73
+ * @param {object} [opts]
74
+ * @param {{getItem: function, setItem: function, removeItem: function}} [opts.storage]
75
+ * Storage backend (defaults to `localStorage` if present, else an
76
+ * in-memory `Map` scoped to this instance).
77
+ * @param {string} [opts.storageKey] - Override the storage key (useful
78
+ * for isolating multiple instances/tests under one storage backend).
79
+ */
80
+ constructor({ storage, storageKey = STORAGE_KEY } = {}) {
81
+ this.#storage = storage || defaultStorage();
82
+ this.#storageKey = storageKey;
83
+ }
84
+
85
+ /**
86
+ * Verify a host's fingerprint against the stored record.
87
+ *
88
+ * @param {string} host - Host label (e.g. "example.com:4422")
89
+ * @param {string} fingerprint - Hex SHA-256 fingerprint to check
90
+ * @returns {{ status: 'known' | 'unknown' | 'changed', expected?: string }}
91
+ */
92
+ verifyHost(host, fingerprint) {
93
+ const entries = this.#load();
94
+ if (!(host in entries)) {
95
+ return { status: 'unknown' };
96
+ }
97
+ if (entries[host] === fingerprint) {
98
+ return { status: 'known' };
99
+ }
100
+ return { status: 'changed', expected: entries[host] };
101
+ }
102
+
103
+ /** Add or update a host's fingerprint. */
104
+ addHost(host, fingerprint) {
105
+ const entries = this.#load();
106
+ entries[host] = fingerprint;
107
+ this.#save(entries);
108
+ }
109
+
110
+ /** Remove a host entry. Returns true if an entry was removed. */
111
+ removeHost(host) {
112
+ const entries = this.#load();
113
+ if (!(host in entries)) return false;
114
+ delete entries[host];
115
+ this.#save(entries);
116
+ return true;
117
+ }
118
+
119
+ /** List all known hosts and their fingerprints. */
120
+ list() {
121
+ const entries = this.#load();
122
+ return Object.entries(entries).map(([host, fingerprint]) => ({ host, fingerprint }));
123
+ }
124
+
125
+ #load() {
126
+ const raw = this.#storage.getItem(this.#storageKey);
127
+ if (!raw) return {};
128
+ try {
129
+ const parsed = JSON.parse(raw);
130
+ return parsed && typeof parsed === 'object' ? parsed : {};
131
+ } catch {
132
+ return {};
133
+ }
134
+ }
135
+
136
+ #save(entries) {
137
+ this.#storage.setItem(this.#storageKey, JSON.stringify(entries));
138
+ }
139
+ }