@johnhenry/wsh 0.16.1 → 0.17.1
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 +349 -28
- package/package.json +16 -3
- package/src/auth.mjs +63 -1
- package/src/client.mjs +379 -35
- package/src/control-listener.mjs +130 -0
- package/src/e2e-frame.mjs +32 -1
- package/src/file-transfer.mjs +47 -192
- package/src/index.d.ts +261 -8
- package/src/index.mjs +10 -2
- package/src/known-hosts.mjs +139 -0
- package/src/mcp-bridge.mjs +70 -51
- package/src/messages.gen.mjs +30 -16
- package/src/mlkem.mjs +27 -0
- package/src/session.mjs +73 -21
- package/src/transport-ws.mjs +16 -2
- package/src/transport.mjs +201 -7
package/src/index.d.ts
CHANGED
|
@@ -228,6 +228,16 @@ export const AUTH_METHOD: {
|
|
|
228
228
|
/** Protocol version string. */
|
|
229
229
|
export const PROTOCOL_VERSION: 'wsh-v1';
|
|
230
230
|
|
|
231
|
+
/**
|
|
232
|
+
* ServerHello feature advertising that McpResult echoes McpCall's call_id.
|
|
233
|
+
*
|
|
234
|
+
* Compare against `WshClient.hasFeature()` before setting `callId`:
|
|
235
|
+
* McpCallPayload is `deny_unknown_fields` server-side, so sending the field
|
|
236
|
+
* to a server predating it makes the server reject the call rather than
|
|
237
|
+
* ignore the field.
|
|
238
|
+
*/
|
|
239
|
+
export const MCP_CALL_ID_FEATURE: 'mcp-call-id';
|
|
240
|
+
|
|
231
241
|
/** A wsh protocol control message (all messages have a numeric `type`). */
|
|
232
242
|
export interface WshMessage {
|
|
233
243
|
type: number;
|
|
@@ -396,10 +406,14 @@ export function mcpTools(opts?: {
|
|
|
396
406
|
export function mcpCall(opts?: {
|
|
397
407
|
tool?: string;
|
|
398
408
|
arguments?: Record<string, unknown>;
|
|
409
|
+
/** Correlates this call with its McpResult. Omitted from the message entirely when undefined. */
|
|
410
|
+
callId?: string;
|
|
399
411
|
}): WshMessage;
|
|
400
412
|
|
|
401
413
|
export function mcpResult(opts?: {
|
|
402
414
|
result?: unknown;
|
|
415
|
+
/** Echoed verbatim from the McpCall this answers. */
|
|
416
|
+
callId?: string;
|
|
403
417
|
}): WshMessage;
|
|
404
418
|
|
|
405
419
|
// -- Message constructors (reverse) --
|
|
@@ -764,6 +778,14 @@ export function fileResult(opts?: {
|
|
|
764
778
|
channelId?: number;
|
|
765
779
|
success?: boolean;
|
|
766
780
|
metadata?: Record<string, unknown>;
|
|
781
|
+
/** wsh #59: typed directory entries for a "list" op (see WshFileListEntry-shaped wire fields). */
|
|
782
|
+
entries?: Array<{
|
|
783
|
+
name: string;
|
|
784
|
+
size: number;
|
|
785
|
+
modified: number;
|
|
786
|
+
type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
|
|
787
|
+
symlink_target?: string;
|
|
788
|
+
}>;
|
|
767
789
|
errorMessage?: string;
|
|
768
790
|
}): WshMessage;
|
|
769
791
|
|
|
@@ -775,6 +797,18 @@ export function fileChunk(opts?: {
|
|
|
775
797
|
totalSize?: number;
|
|
776
798
|
}): WshMessage;
|
|
777
799
|
|
|
800
|
+
/** wsh #59: install a public key into the remote's authorized_keys (see WshClient.addAuthorizedKey). */
|
|
801
|
+
export function authorizedKeyAdd(opts?: {
|
|
802
|
+
publicKey?: Uint8Array;
|
|
803
|
+
comment?: string;
|
|
804
|
+
}): WshMessage;
|
|
805
|
+
|
|
806
|
+
export function authorizedKeyResult(opts?: {
|
|
807
|
+
success?: boolean;
|
|
808
|
+
added?: boolean;
|
|
809
|
+
errorMessage?: string;
|
|
810
|
+
}): WshMessage;
|
|
811
|
+
|
|
778
812
|
export function policyEval(opts?: {
|
|
779
813
|
requestId?: string;
|
|
780
814
|
action?: string;
|
|
@@ -817,7 +851,19 @@ export function isValidMessage(msg: unknown): boolean;
|
|
|
817
851
|
// ============================================================================
|
|
818
852
|
|
|
819
853
|
/**
|
|
820
|
-
*
|
|
854
|
+
* Whether this runtime's WebCrypto implements Ed25519, measured by
|
|
855
|
+
* generating a key rather than sniffed from a version string.
|
|
856
|
+
*
|
|
857
|
+
* There is no pure-JS fallback: one would need the private scalar as
|
|
858
|
+
* ordinary bytes, giving up the non-extractable `CryptoKey` property
|
|
859
|
+
* `WshKeyStore` is built around. Check this before committing to pubkey
|
|
860
|
+
* auth and fall back to password auth where it returns false.
|
|
861
|
+
*/
|
|
862
|
+
export function isEd25519Supported(): Promise<boolean>;
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Generate a new Ed25519 key pair. Throws with an actionable message
|
|
866
|
+
* where WebCrypto has no Ed25519 -- see `isEd25519Supported()`.
|
|
821
867
|
* @param extractable - Whether private key can be exported (default false)
|
|
822
868
|
*/
|
|
823
869
|
export function generateKeyPair(extractable?: boolean): Promise<CryptoKeyPair>;
|
|
@@ -1017,7 +1063,7 @@ export class WshTransport {
|
|
|
1017
1063
|
readonly state: 'disconnected' | 'connecting' | 'connected' | 'closed';
|
|
1018
1064
|
|
|
1019
1065
|
/** Connect to a wsh server. */
|
|
1020
|
-
connect(url: string): Promise<void>;
|
|
1066
|
+
connect(url: string, options?: object): Promise<void>;
|
|
1021
1067
|
|
|
1022
1068
|
/** Gracefully close the transport. */
|
|
1023
1069
|
close(): Promise<void>;
|
|
@@ -1044,7 +1090,7 @@ export class WshTransport {
|
|
|
1044
1090
|
protected _emitError(err: Error): void;
|
|
1045
1091
|
|
|
1046
1092
|
/** @protected Must be overridden by subclasses. */
|
|
1047
|
-
protected _doConnect(url: string): Promise<void>;
|
|
1093
|
+
protected _doConnect(url: string, options?: object): Promise<void>;
|
|
1048
1094
|
|
|
1049
1095
|
/** @protected Must be overridden by subclasses. */
|
|
1050
1096
|
protected _doClose(): Promise<void>;
|
|
@@ -1064,7 +1110,65 @@ export class WshTransport {
|
|
|
1064
1110
|
* - Subsequent streams carry raw byte data (no framing).
|
|
1065
1111
|
* - Server-initiated streams are surfaced via onStreamOpen.
|
|
1066
1112
|
*/
|
|
1067
|
-
export class WebTransportTransport extends WshTransport {
|
|
1113
|
+
export class WebTransportTransport extends WshTransport {
|
|
1114
|
+
/**
|
|
1115
|
+
* @param options Forwarded to the `WebTransport` constructor. Options
|
|
1116
|
+
* passed to `connect()` override these per call.
|
|
1117
|
+
*/
|
|
1118
|
+
constructor(options?: WshWebTransportOptions);
|
|
1119
|
+
|
|
1120
|
+
connect(url: string, options?: WshWebTransportOptions): Promise<void>;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* One entry of `serverCertificateHashes`. The digest may be given as raw
|
|
1125
|
+
* bytes, as hex (with or without `:` separators -- what
|
|
1126
|
+
* `openssl x509 -fingerprint -sha256` prints), or as base64/base64url.
|
|
1127
|
+
*/
|
|
1128
|
+
export interface WshCertificateHash {
|
|
1129
|
+
/** Defaults to `'sha-256'`, the only algorithm the platform accepts today. */
|
|
1130
|
+
algorithm?: string;
|
|
1131
|
+
value: BufferSource | string;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Options forwarded to the `WebTransport` constructor.
|
|
1136
|
+
*
|
|
1137
|
+
* `serverCertificateHashes` is the interesting one: it pins a specific
|
|
1138
|
+
* self-signed certificate by digest, which is the only mechanism in the
|
|
1139
|
+
* web platform that lets page JavaScript reach a server whose certificate
|
|
1140
|
+
* no certificate authority signed. The platform's own constraints apply --
|
|
1141
|
+
* `https:` URL, HTTP/3 only, ECDSA P-256 key, at most 14 days validity,
|
|
1142
|
+
* no connection pooling.
|
|
1143
|
+
*
|
|
1144
|
+
* Every other key is forwarded verbatim, so platform options not listed
|
|
1145
|
+
* here still work.
|
|
1146
|
+
*/
|
|
1147
|
+
export interface WshWebTransportOptions {
|
|
1148
|
+
serverCertificateHashes?: Array<WshCertificateHash | BufferSource | string>;
|
|
1149
|
+
allowPooling?: boolean;
|
|
1150
|
+
requireUnreliable?: boolean;
|
|
1151
|
+
congestionControl?: 'default' | 'throughput' | 'low-latency';
|
|
1152
|
+
anticipatedConcurrentIncomingUnidirectionalStreams?: number | null;
|
|
1153
|
+
anticipatedConcurrentIncomingBidirectionalStreams?: number | null;
|
|
1154
|
+
protocols?: string[];
|
|
1155
|
+
[key: string]: unknown;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* Decode one `serverCertificateHashes` digest into bytes. Accepts a
|
|
1160
|
+
* `BufferSource`, hex (separators and an `... Fingerprint=` prefix are
|
|
1161
|
+
* stripped), or base64/base64url. Throws a `TypeError` on an
|
|
1162
|
+
* unrecognised shape and a `RangeError` on a digest of the wrong length
|
|
1163
|
+
* for the algorithm.
|
|
1164
|
+
*/
|
|
1165
|
+
export function parseCertificateHash(value: BufferSource | string, algorithm?: string): Uint8Array;
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Normalise a caller-supplied options bag into a `WebTransportOptions`
|
|
1169
|
+
* dictionary, or `undefined` when there is nothing to pass.
|
|
1170
|
+
*/
|
|
1171
|
+
export function normalizeWebTransportOptions(options?: WshWebTransportOptions): object | undefined;
|
|
1068
1172
|
|
|
1069
1173
|
/**
|
|
1070
1174
|
* Dispatch a fixed, already-available batch of items one at a time,
|
|
@@ -1307,8 +1411,21 @@ export class WshSession {
|
|
|
1307
1411
|
/** Called when the remote process exits. */
|
|
1308
1412
|
onExit: ((code: number) => void) | null;
|
|
1309
1413
|
|
|
1310
|
-
/**
|
|
1311
|
-
|
|
1414
|
+
/**
|
|
1415
|
+
* Called when the session is fully closed.
|
|
1416
|
+
*
|
|
1417
|
+
* Receives the same value as `closeReason`: an Error for a torn-down
|
|
1418
|
+
* session, null for an ordinary close.
|
|
1419
|
+
*/
|
|
1420
|
+
onClose: ((reason: Error | null) => void) | null;
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* Why the session closed, or null for an ordinary close.
|
|
1424
|
+
*
|
|
1425
|
+
* A torn-down session and a clean exit are otherwise indistinguishable
|
|
1426
|
+
* from the outside.
|
|
1427
|
+
*/
|
|
1428
|
+
readonly closeReason: Error | null;
|
|
1312
1429
|
|
|
1313
1430
|
/** Called when the remote peer acknowledges speculative local echo. */
|
|
1314
1431
|
onEchoAck: ((payload: Record<string, unknown>) => void) | null;
|
|
@@ -1427,6 +1544,12 @@ export interface WshConnectOptions {
|
|
|
1427
1544
|
keyPair?: CryptoKeyPair;
|
|
1428
1545
|
password?: string;
|
|
1429
1546
|
transport?: 'wt' | 'ws';
|
|
1547
|
+
/**
|
|
1548
|
+
* Forwarded to the `WebTransport` constructor when the WebTransport
|
|
1549
|
+
* rung of the transport ladder is tried; ignored by the WebSocket rung,
|
|
1550
|
+
* which has no `serverCertificateHashes` equivalent.
|
|
1551
|
+
*/
|
|
1552
|
+
webTransport?: WshWebTransportOptions;
|
|
1430
1553
|
timeout?: number;
|
|
1431
1554
|
}
|
|
1432
1555
|
|
|
@@ -1466,6 +1589,8 @@ export interface WshConnectReverseOptions {
|
|
|
1466
1589
|
username: string;
|
|
1467
1590
|
keyPair?: CryptoKeyPair;
|
|
1468
1591
|
password?: string;
|
|
1592
|
+
/** Forwarded to the underlying `connect()` -- see `WshConnectOptions`. */
|
|
1593
|
+
webTransport?: WshWebTransportOptions;
|
|
1469
1594
|
expose?: {
|
|
1470
1595
|
shell?: boolean;
|
|
1471
1596
|
exec?: boolean;
|
|
@@ -1544,6 +1669,28 @@ export interface WshE2EResult {
|
|
|
1544
1669
|
hybrid: boolean;
|
|
1545
1670
|
}
|
|
1546
1671
|
|
|
1672
|
+
/**
|
|
1673
|
+
* Raw `FileResult` response to a structured file-channel operation
|
|
1674
|
+
* (`WshClient.fileList`/`fileRemove`/etc.), as received off the wire --
|
|
1675
|
+
* wire field names (snake_case), not the camelCase shape the `fileResult()`
|
|
1676
|
+
* message constructor takes as input. `entries` is only populated for a
|
|
1677
|
+
* `"list"` op; `error_message` is only present on failure.
|
|
1678
|
+
*/
|
|
1679
|
+
export interface WshFileOperationResult {
|
|
1680
|
+
type: number;
|
|
1681
|
+
channel_id: number;
|
|
1682
|
+
success: boolean;
|
|
1683
|
+
metadata: Record<string, unknown>;
|
|
1684
|
+
entries: Array<{
|
|
1685
|
+
name: string;
|
|
1686
|
+
size: number;
|
|
1687
|
+
modified: number;
|
|
1688
|
+
type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
|
|
1689
|
+
symlink_target?: string;
|
|
1690
|
+
}>;
|
|
1691
|
+
error_message?: string;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1547
1694
|
/**
|
|
1548
1695
|
* WshClient -- manages a wsh connection, authentication, and multiple sessions.
|
|
1549
1696
|
*
|
|
@@ -1659,6 +1806,36 @@ export class WshClient {
|
|
|
1659
1806
|
*/
|
|
1660
1807
|
revokeSessionAccess(sessionId: string, principal: string, reason?: string): Promise<void>;
|
|
1661
1808
|
|
|
1809
|
+
/**
|
|
1810
|
+
* List a remote directory over the structured file channel (`FileOp`
|
|
1811
|
+
* op `"list"`). Prefer `WshFileTransfer.list()` for a typed
|
|
1812
|
+
* `WshFileListEntry[]` result (with `modified` as a `Date`) -- this is
|
|
1813
|
+
* the raw wire-shaped `FileResult` response `fileOperation()` waits on.
|
|
1814
|
+
*/
|
|
1815
|
+
fileList(path: string, timeout?: number): Promise<WshFileOperationResult>;
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* Remove a remote file or directory (`FileOp` op `"remove"`). Refused
|
|
1819
|
+
* today by every `wsh-server` release ("not yet implemented" -- see
|
|
1820
|
+
* README's file-op parity table); wired here so clients built against
|
|
1821
|
+
* this SDK don't need to wait on the server to type-check against it.
|
|
1822
|
+
*/
|
|
1823
|
+
fileRemove(path: string, timeout?: number): Promise<WshFileOperationResult>;
|
|
1824
|
+
|
|
1825
|
+
/**
|
|
1826
|
+
* Install a raw 32-byte Ed25519 public key into the remote host's
|
|
1827
|
+
* `~/.wsh/authorized_keys` (wsh #59's `AuthorizedKeyAdd` protocol
|
|
1828
|
+
* message -- replaces `wsh copy-id`'s previous CLI-only shell-command
|
|
1829
|
+
* approach with something every implementation, including this one,
|
|
1830
|
+
* can call directly). Idempotent: installing an already-present key
|
|
1831
|
+
* resolves `added: false` rather than duplicating the line or erroring.
|
|
1832
|
+
*/
|
|
1833
|
+
addAuthorizedKey(
|
|
1834
|
+
publicKeyRaw: Uint8Array,
|
|
1835
|
+
comment?: string,
|
|
1836
|
+
timeout?: number,
|
|
1837
|
+
): Promise<{ added: boolean }>;
|
|
1838
|
+
|
|
1662
1839
|
/**
|
|
1663
1840
|
* Gracefully disconnect: close all sessions and the transport.
|
|
1664
1841
|
*/
|
|
@@ -1697,6 +1874,31 @@ export class WshClient {
|
|
|
1697
1874
|
*/
|
|
1698
1875
|
sendRelayControl(msg: WshMessage): Promise<void>;
|
|
1699
1876
|
|
|
1877
|
+
/**
|
|
1878
|
+
* Send a raw control message on the authenticated connection.
|
|
1879
|
+
*
|
|
1880
|
+
* Prefer the typed methods. This is the hook `WshMcpBridge` and
|
|
1881
|
+
* `WshFileTransfer` compose their own messages through.
|
|
1882
|
+
*/
|
|
1883
|
+
sendControl(msg: WshMessage): Promise<void>;
|
|
1884
|
+
|
|
1885
|
+
/**
|
|
1886
|
+
* Open a raw bidirectional stream on the underlying transport.
|
|
1887
|
+
*/
|
|
1888
|
+
openStream(): Promise<WshStream>;
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Observe every inbound control message this client handles. Listeners
|
|
1892
|
+
* are called past the RelayForward trust gate, so a message from an
|
|
1893
|
+
* unaccepted peer never reaches one.
|
|
1894
|
+
*/
|
|
1895
|
+
addControlListener(fn: (msg: WshMessage) => void): void;
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Stop observing inbound control messages.
|
|
1899
|
+
*/
|
|
1900
|
+
removeControlListener(fn: (msg: WshMessage) => void): void;
|
|
1901
|
+
|
|
1700
1902
|
/**
|
|
1701
1903
|
* Initiate end-to-end encryption for a session: `'X25519'` (default,
|
|
1702
1904
|
* classical ECDH) or `'X25519+ML-KEM-768'` (hybrid, with automatic
|
|
@@ -1863,6 +2065,48 @@ export class WshKeyStore {
|
|
|
1863
2065
|
restore(passphrase: string): Promise<WshRestoreResult>;
|
|
1864
2066
|
}
|
|
1865
2067
|
|
|
2068
|
+
// ============================================================================
|
|
2069
|
+
// known-hosts.mjs -- WshKnownHosts (wsh #59)
|
|
2070
|
+
// ============================================================================
|
|
2071
|
+
|
|
2072
|
+
/** Minimal storage interface `WshKnownHosts` needs -- `localStorage` already satisfies it. */
|
|
2073
|
+
export interface WshKnownHostsStorage {
|
|
2074
|
+
getItem(key: string): string | null;
|
|
2075
|
+
setItem(key: string, value: string): void;
|
|
2076
|
+
removeItem(key: string): void;
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
/** Result of verifying a host's fingerprint against the stored record. */
|
|
2080
|
+
export interface WshHostVerifyResult {
|
|
2081
|
+
status: 'known' | 'unknown' | 'changed';
|
|
2082
|
+
/** Previously stored fingerprint, present only when status === 'changed'. */
|
|
2083
|
+
expected?: string;
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
/**
|
|
2087
|
+
* WshKnownHosts -- trust-on-first-use (TOFU) host-identity store, mirroring
|
|
2088
|
+
* Rust's `KnownHosts`/`HostStatus` (`crates/wsh-client/src/known_hosts.rs`).
|
|
2089
|
+
* Pins `ServerHello.host_fingerprint` (spec/wsh-v1.yaml, wsh #59) -- no
|
|
2090
|
+
* `wsh-server` release populates that field yet, so this has nothing
|
|
2091
|
+
* authoritative to verify against for any real server today. See the
|
|
2092
|
+
* class doc comment in `known-hosts.mjs` for the full reasoning.
|
|
2093
|
+
*/
|
|
2094
|
+
export class WshKnownHosts {
|
|
2095
|
+
constructor(opts?: { storage?: WshKnownHostsStorage; storageKey?: string });
|
|
2096
|
+
|
|
2097
|
+
/** Verify a host's fingerprint against the stored record. */
|
|
2098
|
+
verifyHost(host: string, fingerprint: string): WshHostVerifyResult;
|
|
2099
|
+
|
|
2100
|
+
/** Add or update a host's fingerprint. */
|
|
2101
|
+
addHost(host: string, fingerprint: string): void;
|
|
2102
|
+
|
|
2103
|
+
/** Remove a host entry. Returns true if an entry was removed. */
|
|
2104
|
+
removeHost(host: string): boolean;
|
|
2105
|
+
|
|
2106
|
+
/** List all known hosts and their fingerprints. */
|
|
2107
|
+
list(): Array<{ host: string; fingerprint: string }>;
|
|
2108
|
+
}
|
|
2109
|
+
|
|
1866
2110
|
// ============================================================================
|
|
1867
2111
|
// file-transfer.mjs -- WshFileTransfer
|
|
1868
2112
|
// ============================================================================
|
|
@@ -1885,12 +2129,21 @@ export interface WshFileUploadResult {
|
|
|
1885
2129
|
bytesTransferred: number;
|
|
1886
2130
|
}
|
|
1887
2131
|
|
|
1888
|
-
/**
|
|
2132
|
+
/**
|
|
2133
|
+
* File listing entry (wsh #59: generated from spec/wsh-v1.yaml's
|
|
2134
|
+
* `FileEntry` nested type -- the same shape `crates/wsh-core`'s Rust
|
|
2135
|
+
* `FileEntry` struct is generated from, so a directory entry can no longer
|
|
2136
|
+
* describe a symlink as a plain file in one implementation and not the
|
|
2137
|
+
* other). `modified` is converted from the wire's Unix epoch seconds into
|
|
2138
|
+
* a `Date` for JS ergonomics. `symlinkTarget` is only present when
|
|
2139
|
+
* `type === 'symlink'`.
|
|
2140
|
+
*/
|
|
1889
2141
|
export interface WshFileListEntry {
|
|
1890
2142
|
name: string;
|
|
1891
2143
|
size: number;
|
|
1892
|
-
modified:
|
|
2144
|
+
modified: Date;
|
|
1893
2145
|
type: 'file' | 'directory' | 'symlink' | 'device' | 'pipe' | 'socket';
|
|
2146
|
+
symlinkTarget?: string;
|
|
1894
2147
|
}
|
|
1895
2148
|
|
|
1896
2149
|
/**
|
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,
|
|
@@ -42,6 +43,7 @@ export {
|
|
|
42
43
|
|
|
43
44
|
// Auth + crypto
|
|
44
45
|
export {
|
|
46
|
+
isEd25519Supported,
|
|
45
47
|
generateKeyPair, exportPublicKeyRaw, exportPublicKeySSH,
|
|
46
48
|
importPublicKeyRaw, exportPrivateKeyPKCS8, importPrivateKeyPKCS8,
|
|
47
49
|
sign, verify, buildTranscript, signChallenge, verifyChallenge,
|
|
@@ -51,7 +53,10 @@ export {
|
|
|
51
53
|
} from './auth.mjs';
|
|
52
54
|
|
|
53
55
|
// Transport
|
|
54
|
-
export {
|
|
56
|
+
export {
|
|
57
|
+
WshTransport, WebTransportTransport, dispatchSerially, SerialQueue,
|
|
58
|
+
parseCertificateHash, normalizeWebTransportOptions,
|
|
59
|
+
} from './transport.mjs';
|
|
55
60
|
export { WebSocketTransport, WS_FRAME_TYPE } from './transport-ws.mjs';
|
|
56
61
|
|
|
57
62
|
// QMux (QUIC-v1 frames over an ordered byte stream, draft-ietf-quic-qmux-02)
|
|
@@ -68,11 +73,14 @@ export {
|
|
|
68
73
|
// Session + Client
|
|
69
74
|
export { WshSession } from './session.mjs';
|
|
70
75
|
export { WshVirtualSessionBackend, normalizeSessionData } from './virtual-session.mjs';
|
|
71
|
-
export { WshClient } from './client.mjs';
|
|
76
|
+
export { WshClient, MCP_CALL_ID_FEATURE } from './client.mjs';
|
|
72
77
|
|
|
73
78
|
// Key storage
|
|
74
79
|
export { WshKeyStore } from './keystore.mjs';
|
|
75
80
|
|
|
81
|
+
// Host identity (TOFU) -- wsh #59
|
|
82
|
+
export { WshKnownHosts } from './known-hosts.mjs';
|
|
83
|
+
|
|
76
84
|
// File transfer
|
|
77
85
|
export { WshFileTransfer } from './file-transfer.mjs';
|
|
78
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
|
+
}
|