@hwip/cli 0.0.1 → 0.1.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/LICENSE +190 -0
- package/NOTICE +11 -0
- package/README.md +1979 -0
- package/dist/agent-list.d.ts +76 -0
- package/dist/agent-list.js +102 -0
- package/dist/agent-read.d.ts +105 -0
- package/dist/agent-read.js +309 -0
- package/dist/agent-rules-fetch.d.ts +51 -0
- package/dist/agent-rules-fetch.js +275 -0
- package/dist/agent-rules.default.d.ts +30 -0
- package/dist/agent-rules.default.js +292 -0
- package/dist/agent-session.d.ts +23 -0
- package/dist/agent-session.js +44 -0
- package/dist/agent-state.d.ts +84 -0
- package/dist/agent-state.js +188 -0
- package/dist/agent-target.d.ts +60 -0
- package/dist/agent-target.js +81 -0
- package/dist/agents.d.ts +31 -0
- package/dist/agents.js +75 -0
- package/dist/api.d.ts +28 -0
- package/dist/api.js +76 -0
- package/dist/args.d.ts +74 -0
- package/dist/args.js +254 -0
- package/dist/ask-context-attach.d.ts +73 -0
- package/dist/ask-context-attach.js +151 -0
- package/dist/ask-context-screen.d.ts +21 -0
- package/dist/ask-context-screen.js +49 -0
- package/dist/ask-context-switch.d.ts +31 -0
- package/dist/ask-context-switch.js +74 -0
- package/dist/ask-context.d.ts +84 -0
- package/dist/ask-context.js +172 -0
- package/dist/ask-pending.d.ts +47 -0
- package/dist/ask-pending.js +221 -0
- package/dist/ask-seal.d.ts +120 -0
- package/dist/ask-seal.js +179 -0
- package/dist/ask.d.ts +107 -0
- package/dist/ask.js +551 -0
- package/dist/blocked-prompt-seal.d.ts +35 -0
- package/dist/blocked-prompt-seal.js +89 -0
- package/dist/blocked-prompt.d.ts +57 -0
- package/dist/blocked-prompt.js +181 -0
- package/dist/channel.d.ts +67 -0
- package/dist/channel.js +136 -0
- package/dist/check-update.d.ts +28 -0
- package/dist/check-update.js +153 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +813 -0
- package/dist/codex-config.d.ts +24 -0
- package/dist/codex-config.js +169 -0
- package/dist/codex-transcript.d.ts +59 -0
- package/dist/codex-transcript.js +373 -0
- package/dist/command-refusal.d.ts +56 -0
- package/dist/command-refusal.js +127 -0
- package/dist/command-scan.d.ts +46 -0
- package/dist/command-scan.js +154 -0
- package/dist/command-seal.d.ts +57 -0
- package/dist/command-seal.js +88 -0
- package/dist/command-seq.d.ts +30 -0
- package/dist/command-seq.js +150 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.js +392 -0
- package/dist/core-sections.d.ts +18 -0
- package/dist/core-sections.js +28 -0
- package/dist/crypto.d.ts +278 -0
- package/dist/crypto.js +698 -0
- package/dist/desktop-notify.d.ts +33 -0
- package/dist/desktop-notify.js +49 -0
- package/dist/device-directory.d.ts +36 -0
- package/dist/device-directory.js +97 -0
- package/dist/device-presence.d.ts +17 -0
- package/dist/device-presence.js +62 -0
- package/dist/downloads.d.ts +29 -0
- package/dist/downloads.js +106 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +28 -0
- package/dist/file-crypto.d.ts +94 -0
- package/dist/file-crypto.js +272 -0
- package/dist/file-view-budget.d.ts +15 -0
- package/dist/file-view-budget.js +34 -0
- package/dist/file-view-switch.d.ts +28 -0
- package/dist/file-view-switch.js +122 -0
- package/dist/file-view.d.ts +108 -0
- package/dist/file-view.js +327 -0
- package/dist/gate.d.ts +141 -0
- package/dist/gate.js +336 -0
- package/dist/gemini-transcript.d.ts +32 -0
- package/dist/gemini-transcript.js +189 -0
- package/dist/http-failure.d.ts +98 -0
- package/dist/http-failure.js +349 -0
- package/dist/hwip-core.generated.d.ts +7 -0
- package/dist/hwip-core.generated.js +14 -0
- package/dist/hwip-hook.d.ts +77 -0
- package/dist/hwip-hook.js +340 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +14 -0
- package/dist/input-sequencer.d.ts +65 -0
- package/dist/input-sequencer.js +142 -0
- package/dist/insecure-url.d.ts +16 -0
- package/dist/insecure-url.js +76 -0
- package/dist/installer.d.ts +96 -0
- package/dist/installer.js +850 -0
- package/dist/inventory-offload.d.ts +49 -0
- package/dist/inventory-offload.js +209 -0
- package/dist/inventory-worker.d.ts +2 -0
- package/dist/inventory-worker.js +27 -0
- package/dist/jsonc-doc.d.ts +106 -0
- package/dist/jsonc-doc.js +354 -0
- package/dist/keep-awake.d.ts +32 -0
- package/dist/keep-awake.js +46 -0
- package/dist/key-changes.d.ts +48 -0
- package/dist/key-changes.js +140 -0
- package/dist/lan-auth.d.ts +140 -0
- package/dist/lan-auth.js +217 -0
- package/dist/lan-endpoint.d.ts +41 -0
- package/dist/lan-endpoint.js +115 -0
- package/dist/lan-receiver.d.ts +122 -0
- package/dist/lan-receiver.js +510 -0
- package/dist/lan-sender.d.ts +76 -0
- package/dist/lan-sender.js +184 -0
- package/dist/lan-transfer.d.ts +36 -0
- package/dist/lan-transfer.js +54 -0
- package/dist/listener-device-id.d.ts +20 -0
- package/dist/listener-device-id.js +133 -0
- package/dist/listener-log.d.ts +4 -0
- package/dist/listener-log.js +23 -0
- package/dist/listener-process.d.ts +200 -0
- package/dist/listener-process.js +470 -0
- package/dist/listener-service.d.ts +225 -0
- package/dist/listener-service.js +682 -0
- package/dist/listener-worktree-cleanup.d.ts +27 -0
- package/dist/listener-worktree-cleanup.js +62 -0
- package/dist/listener.d.ts +1325 -0
- package/dist/listener.js +5680 -0
- package/dist/login-address.d.ts +25 -0
- package/dist/login-address.js +97 -0
- package/dist/login-page.d.ts +29 -0
- package/dist/login-page.js +93 -0
- package/dist/login.d.ts +98 -0
- package/dist/login.js +388 -0
- package/dist/mcp-command.d.ts +45 -0
- package/dist/mcp-command.js +50 -0
- package/dist/mcp.d.ts +19 -0
- package/dist/mcp.js +29 -0
- package/dist/node-support.d.ts +19 -0
- package/dist/node-support.js +38 -0
- package/dist/notify-failure.d.ts +57 -0
- package/dist/notify-failure.js +150 -0
- package/dist/opencode-transcript.d.ts +139 -0
- package/dist/opencode-transcript.js +390 -0
- package/dist/optional-features.d.ts +6 -0
- package/dist/optional-features.js +19 -0
- package/dist/pane-fit.d.ts +40 -0
- package/dist/pane-fit.js +119 -0
- package/dist/peer-key.d.ts +33 -0
- package/dist/peer-key.js +34 -0
- package/dist/pi-transcript.d.ts +26 -0
- package/dist/pi-transcript.js +255 -0
- package/dist/presence.d.ts +14 -0
- package/dist/presence.js +124 -0
- package/dist/private-files.d.ts +51 -0
- package/dist/private-files.js +218 -0
- package/dist/project-switch.d.ts +19 -0
- package/dist/project-switch.js +60 -0
- package/dist/redact.d.ts +10 -0
- package/dist/redact.js +21 -0
- package/dist/remote-agents.d.ts +410 -0
- package/dist/remote-agents.js +1218 -0
- package/dist/remote-hook.d.ts +12 -0
- package/dist/remote-hook.js +196 -0
- package/dist/replace-file.d.ts +50 -0
- package/dist/replace-file.js +328 -0
- package/dist/schedule-command.d.ts +35 -0
- package/dist/schedule-command.js +260 -0
- package/dist/schedule-launch.d.ts +15 -0
- package/dist/schedule-launch.js +24 -0
- package/dist/schedule-ready.d.ts +44 -0
- package/dist/schedule-ready.js +71 -0
- package/dist/schedule-remote.d.ts +77 -0
- package/dist/schedule-remote.js +167 -0
- package/dist/schedule-runner.d.ts +89 -0
- package/dist/schedule-runner.js +452 -0
- package/dist/schedule.d.ts +149 -0
- package/dist/schedule.js +428 -0
- package/dist/secret-mask.d.ts +29 -0
- package/dist/secret-mask.js +245 -0
- package/dist/self-register.d.ts +45 -0
- package/dist/self-register.js +67 -0
- package/dist/send.d.ts +56 -0
- package/dist/send.js +104 -0
- package/dist/session-diff.d.ts +161 -0
- package/dist/session-diff.js +445 -0
- package/dist/session-registry.d.ts +143 -0
- package/dist/session-registry.js +216 -0
- package/dist/sessions-report.d.ts +59 -0
- package/dist/sessions-report.js +99 -0
- package/dist/shell-audit.d.ts +34 -0
- package/dist/shell-audit.js +47 -0
- package/dist/subagent-transcripts.d.ts +167 -0
- package/dist/subagent-transcripts.js +437 -0
- package/dist/subscriber-trust.d.ts +128 -0
- package/dist/subscriber-trust.js +397 -0
- package/dist/templates.d.ts +105 -0
- package/dist/templates.js +871 -0
- package/dist/tmux-install.d.ts +44 -0
- package/dist/tmux-install.js +65 -0
- package/dist/tmux-liveness.d.ts +45 -0
- package/dist/tmux-liveness.js +71 -0
- package/dist/tmux-pane.d.ts +55 -0
- package/dist/tmux-pane.js +126 -0
- package/dist/tmux-socket.d.ts +20 -0
- package/dist/tmux-socket.js +292 -0
- package/dist/todo-fold.d.ts +31 -0
- package/dist/todo-fold.js +102 -0
- package/dist/transcript-tail.d.ts +312 -0
- package/dist/transcript-tail.js +718 -0
- package/dist/trust-command.d.ts +49 -0
- package/dist/trust-command.js +200 -0
- package/dist/trust-lock.d.ts +26 -0
- package/dist/trust-lock.js +94 -0
- package/dist/trust-pins.d.ts +37 -0
- package/dist/trust-pins.js +82 -0
- package/dist/turn-recorder-wire.d.ts +70 -0
- package/dist/turn-recorder-wire.js +165 -0
- package/dist/turn-recorder.d.ts +190 -0
- package/dist/turn-recorder.js +695 -0
- package/dist/turn-ring.d.ts +93 -0
- package/dist/turn-ring.js +418 -0
- package/dist/turn-watch.d.ts +177 -0
- package/dist/turn-watch.js +630 -0
- package/dist/types.d.ts +63 -0
- package/dist/types.js +2 -0
- package/dist/uninstall.d.ts +55 -0
- package/dist/uninstall.js +507 -0
- package/dist/usage.d.ts +17 -0
- package/dist/usage.js +421 -0
- package/dist/verify.d.ts +47 -0
- package/dist/verify.js +396 -0
- package/dist/worktree.d.ts +144 -0
- package/dist/worktree.js +310 -0
- package/dist/wrapper.d.ts +254 -0
- package/dist/wrapper.js +704 -0
- package/dist/ws-url.d.ts +109 -0
- package/dist/ws-url.js +202 -0
- package/package.json +78 -10
- package/bin/hwip.js +0 -3
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local transfer (ADR-0013) — sender authentication, shared by every
|
|
3
|
+
* implementation of the contract: `src/lan-auth.ts` in `@hwip/cli`, the iOS
|
|
4
|
+
* app's uploader (hwip `LanAuth.swift`), and any Kotlin one to come. They
|
|
5
|
+
* stay in step by `lan-auth.vectors.json`, not by reading each other.
|
|
6
|
+
*
|
|
7
|
+
* The device keys are ECDH-only (WebCrypto `deriveKey`), so there is no
|
|
8
|
+
* signature primitive. What both sides can compute offline is the ECDH
|
|
9
|
+
* secret, and from it two keys:
|
|
10
|
+
*
|
|
11
|
+
* macKey = HKDF-SHA256(ikm = ECDH(sender priv, receiver pub), salt = "", info = "hwip-lan-v1", 32)
|
|
12
|
+
* metaKey = HKDF-SHA256(same ikm, salt = "", info = "hwip-lan-v1-meta", 32)
|
|
13
|
+
* mac = HMAC-SHA256(macKey, method | path | senderDeviceId | transferId | timestamp | nonce | sha256(body))
|
|
14
|
+
* meta = base64( iv[12] || AES-256-GCM(metaKey, iv, json) ) — upload only
|
|
15
|
+
* receipt = HMAC-SHA256(macKey, "hwip-lan-receipt|" + mac) — on the 200 / 201
|
|
16
|
+
*
|
|
17
|
+
* The receiver derives the same keys from its own private key and the
|
|
18
|
+
* sender's registered public key (the device list it already fetches), so
|
|
19
|
+
* a valid MAC proves the sender holds the private half of a key the
|
|
20
|
+
* account registered — and binds the route, the body, the transfer and
|
|
21
|
+
* the moment. The MAC over `sha256(body)` can only be checked once the
|
|
22
|
+
* body has arrived; the header-level checks (shape, clock, nonce, known
|
|
23
|
+
* sender) run before a byte of it is read. The metadata is sealed because
|
|
24
|
+
* the transport is plain HTTP on a LAN: a sniffer sees a sender device id
|
|
25
|
+
* and a transfer id, never a file name or the account's device list.
|
|
26
|
+
*
|
|
27
|
+
* The receipt runs the other way. Without it any host that answers on the
|
|
28
|
+
* published address — an ARP spoof is enough — could return 201, and the
|
|
29
|
+
* sender would skip the relay for a file that never landed. Only the device
|
|
30
|
+
* holding the receiver's private key derives `macKey`, so a receipt over the
|
|
31
|
+
* request's own MAC proves this answer, for this request, came from it.
|
|
32
|
+
*/
|
|
33
|
+
export declare const LAN_AUTH_INFO = "hwip-lan-v1";
|
|
34
|
+
export declare const LAN_META_INFO = "hwip-lan-v1-meta";
|
|
35
|
+
/** A request whose timestamp is further from now than this is refused. */
|
|
36
|
+
export declare const LAN_CLOCK_SKEW_MS: number;
|
|
37
|
+
export declare const LAN_NONCE_TTL_MS: number;
|
|
38
|
+
/** Nonces remembered per sender within the window — a legitimate device sends
|
|
39
|
+
* two per transfer, so this is hundreds of transfers in five minutes. A sender
|
|
40
|
+
* that exceeds it is refused until entries expire, never trimmed. */
|
|
41
|
+
export declare const LAN_NONCES_PER_SENDER = 1000;
|
|
42
|
+
/** The ping has no transfer; this stands in so the canonical string keeps its shape. */
|
|
43
|
+
export declare const LAN_PING_TRANSFER_ID = "ping";
|
|
44
|
+
export declare const LAN_PATHS: {
|
|
45
|
+
readonly ping: "/hwip/lan/v1/ping";
|
|
46
|
+
readonly upload: "/hwip/lan/v1/upload";
|
|
47
|
+
};
|
|
48
|
+
/** Header names, lower-case as Node hands them to a server. */
|
|
49
|
+
export declare const LAN_HEADERS: {
|
|
50
|
+
readonly sender: "x-hwip-lan-sender";
|
|
51
|
+
readonly transfer: "x-hwip-lan-transfer";
|
|
52
|
+
readonly timestamp: "x-hwip-lan-timestamp";
|
|
53
|
+
readonly nonce: "x-hwip-lan-nonce";
|
|
54
|
+
readonly mac: "x-hwip-lan-mac";
|
|
55
|
+
/** Sealed `{ fileName, fileType, fileSize, iv, deviceKeyMap, transferId }` — upload only. */
|
|
56
|
+
readonly meta: "x-hwip-lan-meta";
|
|
57
|
+
/** On the receiver's 200 / 201: `signLanReceipt(macKey, request mac)`. */
|
|
58
|
+
readonly receipt: "x-hwip-lan-receipt";
|
|
59
|
+
};
|
|
60
|
+
export interface LanAuthFields {
|
|
61
|
+
/** HTTP method, upper-case. */
|
|
62
|
+
method: string;
|
|
63
|
+
/** Request path, one of `LAN_PATHS`. */
|
|
64
|
+
path: string;
|
|
65
|
+
senderDeviceId: string;
|
|
66
|
+
transferId: string;
|
|
67
|
+
/** Unix time in milliseconds, the sender's clock. */
|
|
68
|
+
timestamp: number;
|
|
69
|
+
/** 16 random bytes, lower-case hex. */
|
|
70
|
+
nonce: string;
|
|
71
|
+
/** SHA-256 of the raw request body, lower-case hex. Empty body → hash of nothing. */
|
|
72
|
+
bodySha256: string;
|
|
73
|
+
}
|
|
74
|
+
/** A transfer id is a single safe path segment — it names a landing directory. */
|
|
75
|
+
export declare const isTransferId: (v: string) => boolean;
|
|
76
|
+
export declare const sha256Hex: (data: Buffer | string) => string;
|
|
77
|
+
export declare const EMPTY_BODY_SHA256: string;
|
|
78
|
+
/** Incremental body hash for a streamed request. */
|
|
79
|
+
export declare const createBodyHasher: () => {
|
|
80
|
+
update: (chunk: Buffer) => void;
|
|
81
|
+
digestHex: () => string;
|
|
82
|
+
};
|
|
83
|
+
export interface LanKeys {
|
|
84
|
+
macKey: Buffer;
|
|
85
|
+
metaKey: Buffer;
|
|
86
|
+
}
|
|
87
|
+
/** Both keys from the raw ECDH secret. */
|
|
88
|
+
export declare const deriveLanKeys: (sharedSecret: Buffer) => LanKeys;
|
|
89
|
+
export declare const canonicalString: (f: LanAuthFields) => string;
|
|
90
|
+
export declare const signLanRequest: (macKey: Buffer, f: LanAuthFields) => string;
|
|
91
|
+
/** Constant-time compare; a MAC of the wrong length is simply wrong. */
|
|
92
|
+
export declare const verifyLanMac: (macKey: Buffer, f: LanAuthFields, mac: string) => boolean;
|
|
93
|
+
export declare const newNonce: () => string;
|
|
94
|
+
/** The receiver's proof that it, and not whatever answered on the address, accepted `requestMac`. */
|
|
95
|
+
export declare const signLanReceipt: (macKey: Buffer, requestMac: string) => string;
|
|
96
|
+
/** Constant-time; a missing or malformed receipt is simply wrong. */
|
|
97
|
+
export declare const verifyLanReceipt: (macKey: Buffer, requestMac: string, receipt: string | null | undefined) => boolean;
|
|
98
|
+
/** Largest sealed header accepted (base64 characters). A 5-device key map is
|
|
99
|
+
* well under 2 KiB; the ceiling stays under Node's 16 KiB `maxHeaderSize`
|
|
100
|
+
* for all headers together, so it is this check that refuses, not the parser. */
|
|
101
|
+
export declare const LAN_META_MAX_BYTES: number;
|
|
102
|
+
/** `iv || ciphertext || tag`, base64 — what goes in `X-Hwip-Lan-Meta`. */
|
|
103
|
+
export declare const sealMeta: (metaKey: Buffer, meta: object, iv?: Buffer) => string;
|
|
104
|
+
/** Inverse of `sealMeta`. Null on any shape or authentication failure — never throws. */
|
|
105
|
+
export declare const openMeta: (metaKey: Buffer, sealed: string | string[] | undefined) => unknown;
|
|
106
|
+
export type LanAuthHeaderFields = Omit<LanAuthFields, 'bodySha256' | 'method' | 'path'> & {
|
|
107
|
+
mac: string;
|
|
108
|
+
};
|
|
109
|
+
/** What a sender puts on the request; the receiver parses it back with `parseLanAuthHeaders`. */
|
|
110
|
+
export declare const toLanAuthHeaders: (f: Omit<LanAuthFields, "bodySha256" | "method" | "path">, mac: string) => Record<string, string>;
|
|
111
|
+
/**
|
|
112
|
+
* Shape check only — null when any header is missing or malformed. Runs
|
|
113
|
+
* before the body, before any crypto, before any lookup: a probe that
|
|
114
|
+
* gets the shape wrong costs nothing and learns nothing.
|
|
115
|
+
*/
|
|
116
|
+
export declare const parseLanAuthHeaders: (headers: Record<string, string | string[] | undefined>) => LanAuthHeaderFields | null;
|
|
117
|
+
export declare const isTimestampFresh: (timestamp: number, now?: number, skewMs?: number) => boolean;
|
|
118
|
+
export interface NonceRegistry {
|
|
119
|
+
/** True when `nonce` has been recorded for `sender` within the TTL. */
|
|
120
|
+
seen: (sender: string, nonce: string, now?: number) => boolean;
|
|
121
|
+
/** Record a verified nonce. False when it was already there (a concurrent
|
|
122
|
+
* replay won the race) or the sender's bucket is full — the caller
|
|
123
|
+
* refuses in both cases. */
|
|
124
|
+
commit: (sender: string, nonce: string, now?: number) => boolean;
|
|
125
|
+
size: () => number;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Replay guard. In memory on purpose (ADR-0013): a restart reopens a
|
|
129
|
+
* window of one clock-skew period, and the worst case is the same
|
|
130
|
+
* ciphertext saved once more as `name (2).ext`.
|
|
131
|
+
*
|
|
132
|
+
* Two properties an earlier shape lacked: nothing is recorded until the
|
|
133
|
+
* MAC has verified, so an unauthenticated peer cannot touch it; and it is
|
|
134
|
+
* partitioned per sender with a fixed quota that refuses rather than
|
|
135
|
+
* evicts, so no device — not even a hostile one on the account — can push
|
|
136
|
+
* another device's nonce out and reopen a replay. Expired entries are the
|
|
137
|
+
* only thing ever removed.
|
|
138
|
+
*/
|
|
139
|
+
export declare const createNonceRegistry: (perSender?: number, ttlMs?: number) => NonceRegistry;
|
|
140
|
+
//# sourceMappingURL=lan-auth.d.ts.map
|
package/dist/lan-auth.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createNonceRegistry = exports.isTimestampFresh = exports.parseLanAuthHeaders = exports.toLanAuthHeaders = exports.openMeta = exports.sealMeta = exports.LAN_META_MAX_BYTES = exports.verifyLanReceipt = exports.signLanReceipt = exports.newNonce = exports.verifyLanMac = exports.signLanRequest = exports.canonicalString = exports.deriveLanKeys = exports.createBodyHasher = exports.EMPTY_BODY_SHA256 = exports.sha256Hex = exports.isTransferId = exports.LAN_HEADERS = exports.LAN_PATHS = exports.LAN_PING_TRANSFER_ID = exports.LAN_NONCES_PER_SENDER = exports.LAN_NONCE_TTL_MS = exports.LAN_CLOCK_SKEW_MS = exports.LAN_META_INFO = exports.LAN_AUTH_INFO = void 0;
|
|
4
|
+
const node_crypto_1 = require("node:crypto");
|
|
5
|
+
/**
|
|
6
|
+
* Local transfer (ADR-0013) — sender authentication, shared by every
|
|
7
|
+
* implementation of the contract: `src/lan-auth.ts` in `@hwip/cli`, the iOS
|
|
8
|
+
* app's uploader (hwip `LanAuth.swift`), and any Kotlin one to come. They
|
|
9
|
+
* stay in step by `lan-auth.vectors.json`, not by reading each other.
|
|
10
|
+
*
|
|
11
|
+
* The device keys are ECDH-only (WebCrypto `deriveKey`), so there is no
|
|
12
|
+
* signature primitive. What both sides can compute offline is the ECDH
|
|
13
|
+
* secret, and from it two keys:
|
|
14
|
+
*
|
|
15
|
+
* macKey = HKDF-SHA256(ikm = ECDH(sender priv, receiver pub), salt = "", info = "hwip-lan-v1", 32)
|
|
16
|
+
* metaKey = HKDF-SHA256(same ikm, salt = "", info = "hwip-lan-v1-meta", 32)
|
|
17
|
+
* mac = HMAC-SHA256(macKey, method | path | senderDeviceId | transferId | timestamp | nonce | sha256(body))
|
|
18
|
+
* meta = base64( iv[12] || AES-256-GCM(metaKey, iv, json) ) — upload only
|
|
19
|
+
* receipt = HMAC-SHA256(macKey, "hwip-lan-receipt|" + mac) — on the 200 / 201
|
|
20
|
+
*
|
|
21
|
+
* The receiver derives the same keys from its own private key and the
|
|
22
|
+
* sender's registered public key (the device list it already fetches), so
|
|
23
|
+
* a valid MAC proves the sender holds the private half of a key the
|
|
24
|
+
* account registered — and binds the route, the body, the transfer and
|
|
25
|
+
* the moment. The MAC over `sha256(body)` can only be checked once the
|
|
26
|
+
* body has arrived; the header-level checks (shape, clock, nonce, known
|
|
27
|
+
* sender) run before a byte of it is read. The metadata is sealed because
|
|
28
|
+
* the transport is plain HTTP on a LAN: a sniffer sees a sender device id
|
|
29
|
+
* and a transfer id, never a file name or the account's device list.
|
|
30
|
+
*
|
|
31
|
+
* The receipt runs the other way. Without it any host that answers on the
|
|
32
|
+
* published address — an ARP spoof is enough — could return 201, and the
|
|
33
|
+
* sender would skip the relay for a file that never landed. Only the device
|
|
34
|
+
* holding the receiver's private key derives `macKey`, so a receipt over the
|
|
35
|
+
* request's own MAC proves this answer, for this request, came from it.
|
|
36
|
+
*/
|
|
37
|
+
exports.LAN_AUTH_INFO = 'hwip-lan-v1';
|
|
38
|
+
exports.LAN_META_INFO = 'hwip-lan-v1-meta';
|
|
39
|
+
/** A request whose timestamp is further from now than this is refused. */
|
|
40
|
+
exports.LAN_CLOCK_SKEW_MS = 5 * 60_000;
|
|
41
|
+
exports.LAN_NONCE_TTL_MS = exports.LAN_CLOCK_SKEW_MS;
|
|
42
|
+
/** Nonces remembered per sender within the window — a legitimate device sends
|
|
43
|
+
* two per transfer, so this is hundreds of transfers in five minutes. A sender
|
|
44
|
+
* that exceeds it is refused until entries expire, never trimmed. */
|
|
45
|
+
exports.LAN_NONCES_PER_SENDER = 1000;
|
|
46
|
+
/** The ping has no transfer; this stands in so the canonical string keeps its shape. */
|
|
47
|
+
exports.LAN_PING_TRANSFER_ID = 'ping';
|
|
48
|
+
exports.LAN_PATHS = {
|
|
49
|
+
ping: '/hwip/lan/v1/ping',
|
|
50
|
+
upload: '/hwip/lan/v1/upload',
|
|
51
|
+
};
|
|
52
|
+
/** Header names, lower-case as Node hands them to a server. */
|
|
53
|
+
exports.LAN_HEADERS = {
|
|
54
|
+
sender: 'x-hwip-lan-sender',
|
|
55
|
+
transfer: 'x-hwip-lan-transfer',
|
|
56
|
+
timestamp: 'x-hwip-lan-timestamp',
|
|
57
|
+
nonce: 'x-hwip-lan-nonce',
|
|
58
|
+
mac: 'x-hwip-lan-mac',
|
|
59
|
+
/** Sealed `{ fileName, fileType, fileSize, iv, deviceKeyMap, transferId }` — upload only. */
|
|
60
|
+
meta: 'x-hwip-lan-meta',
|
|
61
|
+
/** On the receiver's 200 / 201: `signLanReceipt(macKey, request mac)`. */
|
|
62
|
+
receipt: 'x-hwip-lan-receipt',
|
|
63
|
+
};
|
|
64
|
+
const DEVICE_ID = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
65
|
+
const TRANSFER_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
66
|
+
const HEX_32 = /^[0-9a-f]{32}$/;
|
|
67
|
+
const HEX_64 = /^[0-9a-f]{64}$/;
|
|
68
|
+
const TIMESTAMP = /^[0-9]{1,16}$/;
|
|
69
|
+
/** A transfer id is a single safe path segment — it names a landing directory. */
|
|
70
|
+
const isTransferId = (v) => TRANSFER_ID.test(v);
|
|
71
|
+
exports.isTransferId = isTransferId;
|
|
72
|
+
const sha256Hex = (data) => (0, node_crypto_1.createHash)('sha256').update(data).digest('hex');
|
|
73
|
+
exports.sha256Hex = sha256Hex;
|
|
74
|
+
exports.EMPTY_BODY_SHA256 = (0, exports.sha256Hex)(Buffer.alloc(0));
|
|
75
|
+
/** Incremental body hash for a streamed request. */
|
|
76
|
+
const createBodyHasher = () => {
|
|
77
|
+
const hash = (0, node_crypto_1.createHash)('sha256');
|
|
78
|
+
return { update: (chunk) => { hash.update(chunk); }, digestHex: () => hash.digest('hex') };
|
|
79
|
+
};
|
|
80
|
+
exports.createBodyHasher = createBodyHasher;
|
|
81
|
+
/** Both keys from the raw ECDH secret. */
|
|
82
|
+
const deriveLanKeys = (sharedSecret) => ({
|
|
83
|
+
macKey: Buffer.from((0, node_crypto_1.hkdfSync)('sha256', sharedSecret, Buffer.alloc(0), exports.LAN_AUTH_INFO, 32)),
|
|
84
|
+
metaKey: Buffer.from((0, node_crypto_1.hkdfSync)('sha256', sharedSecret, Buffer.alloc(0), exports.LAN_META_INFO, 32)),
|
|
85
|
+
});
|
|
86
|
+
exports.deriveLanKeys = deriveLanKeys;
|
|
87
|
+
const canonicalString = (f) => [f.method, f.path, f.senderDeviceId, f.transferId, String(f.timestamp), f.nonce, f.bodySha256].join('|');
|
|
88
|
+
exports.canonicalString = canonicalString;
|
|
89
|
+
const signLanRequest = (macKey, f) => (0, node_crypto_1.createHmac)('sha256', macKey).update((0, exports.canonicalString)(f)).digest('hex');
|
|
90
|
+
exports.signLanRequest = signLanRequest;
|
|
91
|
+
/** Constant-time compare; a MAC of the wrong length is simply wrong. */
|
|
92
|
+
const verifyLanMac = (macKey, f, mac) => {
|
|
93
|
+
if (!HEX_64.test(mac))
|
|
94
|
+
return false;
|
|
95
|
+
const expected = Buffer.from((0, exports.signLanRequest)(macKey, f), 'hex');
|
|
96
|
+
return (0, node_crypto_1.timingSafeEqual)(expected, Buffer.from(mac, 'hex'));
|
|
97
|
+
};
|
|
98
|
+
exports.verifyLanMac = verifyLanMac;
|
|
99
|
+
const newNonce = () => (0, node_crypto_1.randomBytes)(16).toString('hex');
|
|
100
|
+
exports.newNonce = newNonce;
|
|
101
|
+
const LAN_RECEIPT_PREFIX = 'hwip-lan-receipt|';
|
|
102
|
+
/** The receiver's proof that it, and not whatever answered on the address, accepted `requestMac`. */
|
|
103
|
+
const signLanReceipt = (macKey, requestMac) => (0, node_crypto_1.createHmac)('sha256', macKey).update(LAN_RECEIPT_PREFIX + requestMac).digest('hex');
|
|
104
|
+
exports.signLanReceipt = signLanReceipt;
|
|
105
|
+
/** Constant-time; a missing or malformed receipt is simply wrong. */
|
|
106
|
+
const verifyLanReceipt = (macKey, requestMac, receipt) => {
|
|
107
|
+
if (typeof receipt !== 'string' || !HEX_64.test(receipt))
|
|
108
|
+
return false;
|
|
109
|
+
return (0, node_crypto_1.timingSafeEqual)(Buffer.from((0, exports.signLanReceipt)(macKey, requestMac), 'hex'), Buffer.from(receipt, 'hex'));
|
|
110
|
+
};
|
|
111
|
+
exports.verifyLanReceipt = verifyLanReceipt;
|
|
112
|
+
// ─── Sealed metadata ───
|
|
113
|
+
const META_IV_BYTES = 12;
|
|
114
|
+
const META_TAG_BYTES = 16;
|
|
115
|
+
/** Largest sealed header accepted (base64 characters). A 5-device key map is
|
|
116
|
+
* well under 2 KiB; the ceiling stays under Node's 16 KiB `maxHeaderSize`
|
|
117
|
+
* for all headers together, so it is this check that refuses, not the parser. */
|
|
118
|
+
exports.LAN_META_MAX_BYTES = 8 * 1024;
|
|
119
|
+
/** `iv || ciphertext || tag`, base64 — what goes in `X-Hwip-Lan-Meta`. */
|
|
120
|
+
const sealMeta = (metaKey, meta, iv = (0, node_crypto_1.randomBytes)(META_IV_BYTES)) => {
|
|
121
|
+
const cipher = (0, node_crypto_1.createCipheriv)('aes-256-gcm', metaKey, iv);
|
|
122
|
+
const body = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(meta), 'utf8')), cipher.final()]);
|
|
123
|
+
return Buffer.concat([iv, body, cipher.getAuthTag()]).toString('base64');
|
|
124
|
+
};
|
|
125
|
+
exports.sealMeta = sealMeta;
|
|
126
|
+
/** Inverse of `sealMeta`. Null on any shape or authentication failure — never throws. */
|
|
127
|
+
const openMeta = (metaKey, sealed) => {
|
|
128
|
+
if (typeof sealed !== 'string' || sealed.length === 0 || sealed.length > exports.LAN_META_MAX_BYTES)
|
|
129
|
+
return null;
|
|
130
|
+
const raw = Buffer.from(sealed, 'base64');
|
|
131
|
+
if (raw.length < META_IV_BYTES + META_TAG_BYTES)
|
|
132
|
+
return null;
|
|
133
|
+
try {
|
|
134
|
+
const decipher = (0, node_crypto_1.createDecipheriv)('aes-256-gcm', metaKey, raw.subarray(0, META_IV_BYTES));
|
|
135
|
+
decipher.setAuthTag(raw.subarray(raw.length - META_TAG_BYTES));
|
|
136
|
+
const json = Buffer.concat([decipher.update(raw.subarray(META_IV_BYTES, raw.length - META_TAG_BYTES)), decipher.final()]);
|
|
137
|
+
return JSON.parse(json.toString('utf8'));
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
exports.openMeta = openMeta;
|
|
144
|
+
/** What a sender puts on the request; the receiver parses it back with `parseLanAuthHeaders`. */
|
|
145
|
+
const toLanAuthHeaders = (f, mac) => ({
|
|
146
|
+
[exports.LAN_HEADERS.sender]: f.senderDeviceId,
|
|
147
|
+
[exports.LAN_HEADERS.transfer]: f.transferId,
|
|
148
|
+
[exports.LAN_HEADERS.timestamp]: String(f.timestamp),
|
|
149
|
+
[exports.LAN_HEADERS.nonce]: f.nonce,
|
|
150
|
+
[exports.LAN_HEADERS.mac]: mac,
|
|
151
|
+
});
|
|
152
|
+
exports.toLanAuthHeaders = toLanAuthHeaders;
|
|
153
|
+
const one = (v) => (Array.isArray(v) ? undefined : v);
|
|
154
|
+
/**
|
|
155
|
+
* Shape check only — null when any header is missing or malformed. Runs
|
|
156
|
+
* before the body, before any crypto, before any lookup: a probe that
|
|
157
|
+
* gets the shape wrong costs nothing and learns nothing.
|
|
158
|
+
*/
|
|
159
|
+
const parseLanAuthHeaders = (headers) => {
|
|
160
|
+
const senderDeviceId = one(headers[exports.LAN_HEADERS.sender]);
|
|
161
|
+
const transferId = one(headers[exports.LAN_HEADERS.transfer]);
|
|
162
|
+
const timestamp = one(headers[exports.LAN_HEADERS.timestamp]);
|
|
163
|
+
const nonce = one(headers[exports.LAN_HEADERS.nonce]);
|
|
164
|
+
const mac = one(headers[exports.LAN_HEADERS.mac]);
|
|
165
|
+
if (senderDeviceId === undefined || !DEVICE_ID.test(senderDeviceId)
|
|
166
|
+
|| transferId === undefined || !TRANSFER_ID.test(transferId)
|
|
167
|
+
|| timestamp === undefined || !TIMESTAMP.test(timestamp)
|
|
168
|
+
|| nonce === undefined || !HEX_32.test(nonce)
|
|
169
|
+
|| mac === undefined || !HEX_64.test(mac))
|
|
170
|
+
return null;
|
|
171
|
+
return { senderDeviceId, transferId, timestamp: Number(timestamp), nonce, mac };
|
|
172
|
+
};
|
|
173
|
+
exports.parseLanAuthHeaders = parseLanAuthHeaders;
|
|
174
|
+
const isTimestampFresh = (timestamp, now = Date.now(), skewMs = exports.LAN_CLOCK_SKEW_MS) => Math.abs(now - timestamp) <= skewMs;
|
|
175
|
+
exports.isTimestampFresh = isTimestampFresh;
|
|
176
|
+
/**
|
|
177
|
+
* Replay guard. In memory on purpose (ADR-0013): a restart reopens a
|
|
178
|
+
* window of one clock-skew period, and the worst case is the same
|
|
179
|
+
* ciphertext saved once more as `name (2).ext`.
|
|
180
|
+
*
|
|
181
|
+
* Two properties an earlier shape lacked: nothing is recorded until the
|
|
182
|
+
* MAC has verified, so an unauthenticated peer cannot touch it; and it is
|
|
183
|
+
* partitioned per sender with a fixed quota that refuses rather than
|
|
184
|
+
* evicts, so no device — not even a hostile one on the account — can push
|
|
185
|
+
* another device's nonce out and reopen a replay. Expired entries are the
|
|
186
|
+
* only thing ever removed.
|
|
187
|
+
*/
|
|
188
|
+
const createNonceRegistry = (perSender = exports.LAN_NONCES_PER_SENDER, ttlMs = exports.LAN_NONCE_TTL_MS) => {
|
|
189
|
+
const buckets = new Map(); // sender → nonce → recorded at (insertion-ordered)
|
|
190
|
+
const bucketFor = (sender, now) => {
|
|
191
|
+
const bucket = buckets.get(sender) ?? new Map();
|
|
192
|
+
for (const [nonce, at] of bucket) {
|
|
193
|
+
if (now - at <= ttlMs)
|
|
194
|
+
break; // the rest are newer
|
|
195
|
+
bucket.delete(nonce);
|
|
196
|
+
}
|
|
197
|
+
if (bucket.size === 0)
|
|
198
|
+
buckets.delete(sender);
|
|
199
|
+
else
|
|
200
|
+
buckets.set(sender, bucket);
|
|
201
|
+
return bucket;
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
seen: (sender, nonce, now = Date.now()) => bucketFor(sender, now).has(nonce),
|
|
205
|
+
commit: (sender, nonce, now = Date.now()) => {
|
|
206
|
+
const bucket = bucketFor(sender, now);
|
|
207
|
+
if (bucket.has(nonce) || bucket.size >= perSender)
|
|
208
|
+
return false;
|
|
209
|
+
bucket.set(nonce, now);
|
|
210
|
+
buckets.set(sender, bucket);
|
|
211
|
+
return true;
|
|
212
|
+
},
|
|
213
|
+
size: () => { let n = 0; for (const b of buckets.values())
|
|
214
|
+
n += b.size; return n; },
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
exports.createNonceRegistry = createNonceRegistry;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type NetworkInterfaceInfo } from 'node:os';
|
|
2
|
+
export declare const isPrivateIpv4: (host: string) => boolean;
|
|
3
|
+
/**
|
|
4
|
+
* The private, non-internal IPv4 a phone on the same network is likeliest to
|
|
5
|
+
* reach, or null when the machine has none up. Taking the first one found
|
|
6
|
+
* published a Tailscale, docker or VM-bridge address whenever that interface
|
|
7
|
+
* sorted first, and every file then waited out the 1.5 s LAN probe before
|
|
8
|
+
* taking the relay. Wi-Fi / Ethernet beat unknown interfaces, which beat
|
|
9
|
+
* tunnels and bridges; within that, RFC 1918 beats link-local beats CGNAT.
|
|
10
|
+
* Ties keep the OS's order. Names only — no route lookup, so it stays a pure
|
|
11
|
+
* function of `networkInterfaces()`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const pickLanIpv4: (interfaces?: NodeJS.Dict<NetworkInterfaceInfo[]>) => string | null;
|
|
14
|
+
export interface LanPublisherDeps {
|
|
15
|
+
log: (msg: string) => void;
|
|
16
|
+
/** Send one frame on the live socket; false when there is none. The
|
|
17
|
+
* server's answer (`listener.presence.ack` / `.error`) arrives on the
|
|
18
|
+
* socket's message handler, not here. */
|
|
19
|
+
send: (msg: object) => boolean;
|
|
20
|
+
pickHost?: () => string | null;
|
|
21
|
+
}
|
|
22
|
+
export interface LanPublisher {
|
|
23
|
+
/**
|
|
24
|
+
* Publish `{host, port}` if it differs from what was last published (or
|
|
25
|
+
* always, with `force` — used on every WebSocket open, because the device
|
|
26
|
+
* record may have been recreated). True when a frame went out. Never
|
|
27
|
+
* throws: a socket that is not there leaves the relay path unaffected.
|
|
28
|
+
*/
|
|
29
|
+
publish: (port: number, opts?: {
|
|
30
|
+
force?: boolean;
|
|
31
|
+
}) => boolean;
|
|
32
|
+
/** `lan: null` on the device record — shutdown, or the LAN went away.
|
|
33
|
+
* Best-effort: every reader gates on `isOnline`, so an endpoint left
|
|
34
|
+
* behind by a socket that dropped is already inert. */
|
|
35
|
+
clear: () => void;
|
|
36
|
+
/** Poll the address and republish on change. One watcher at a time. */
|
|
37
|
+
startWatch: (port: number, intervalMs?: number) => void;
|
|
38
|
+
stopWatch: () => void;
|
|
39
|
+
}
|
|
40
|
+
export declare const createLanPublisher: (deps: LanPublisherDeps) => LanPublisher;
|
|
41
|
+
//# sourceMappingURL=lan-endpoint.d.ts.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createLanPublisher = exports.pickLanIpv4 = exports.isPrivateIpv4 = void 0;
|
|
4
|
+
const node_os_1 = require("node:os");
|
|
5
|
+
const device_presence_js_1 = require("./device-presence.js");
|
|
6
|
+
/**
|
|
7
|
+
* Local transfer (ADR-0013) — the listener's side of the rendezvous.
|
|
8
|
+
*
|
|
9
|
+
* There is no mDNS. The listener writes `lan: { host, port }` on its own
|
|
10
|
+
* device record by sending `listener.presence` over the WebSocket it already
|
|
11
|
+
* holds; a sender on the same network reads it back from `GET /devices` and
|
|
12
|
+
* tries a direct upload before the S3 relay. The socket is the transport
|
|
13
|
+
* because it is what proves which device is speaking — the HTTP device-record
|
|
14
|
+
* route is JWT-only and the daemon holds an API key.
|
|
15
|
+
*
|
|
16
|
+
* Liveness is the device's `isOnline` (that same socket), so this module
|
|
17
|
+
* republishes on every reconnect and otherwise only when the address moves.
|
|
18
|
+
*
|
|
19
|
+
* The server refuses anything but a private IPv4, so `pickLanIpv4` applies
|
|
20
|
+
* the same ranges here: a VPN or public address never reaches the wire.
|
|
21
|
+
*/
|
|
22
|
+
/** RFC 1918, CGNAT (100.64/10) and link-local. Same set as the server's validator. */
|
|
23
|
+
const PRIVATE_IPV4 = /^(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3}|100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\.\d{1,3}\.\d{1,3}|169\.254\.\d{1,3}\.\d{1,3})$/;
|
|
24
|
+
const isPrivateIpv4 = (host) => PRIVATE_IPV4.test(host) && host.split('.').every((octet) => Number(octet) <= 255);
|
|
25
|
+
exports.isPrivateIpv4 = isPrivateIpv4;
|
|
26
|
+
/** Wi-Fi and Ethernet: macOS `en*`, Linux `eth*` / `en*` / `wlan*` / `wl*`. */
|
|
27
|
+
const PHYSICAL_IFACE = /^(en|eth|wlan|wl)/;
|
|
28
|
+
/** Tunnels, VPNs, containers and VM bridges: a phone on the Wi-Fi cannot reach these. */
|
|
29
|
+
const VIRTUAL_IFACE = /^(utun|tun|tap|ppp|ipsec|wg|tailscale|zt|docker|br-|bridge|virbr|veth|vmnet|vboxnet|awdl|llw|anpi)/;
|
|
30
|
+
const ifaceRank = (name) => (VIRTUAL_IFACE.test(name) ? 2 : PHYSICAL_IFACE.test(name) ? 0 : 1);
|
|
31
|
+
/** RFC 1918 first; link-local next; CGNAT (Tailscale's 100.64/10) last. */
|
|
32
|
+
const addressRank = (address) => address.startsWith('169.254.') ? 1 : address.startsWith('100.') ? 2 : 0;
|
|
33
|
+
/**
|
|
34
|
+
* The private, non-internal IPv4 a phone on the same network is likeliest to
|
|
35
|
+
* reach, or null when the machine has none up. Taking the first one found
|
|
36
|
+
* published a Tailscale, docker or VM-bridge address whenever that interface
|
|
37
|
+
* sorted first, and every file then waited out the 1.5 s LAN probe before
|
|
38
|
+
* taking the relay. Wi-Fi / Ethernet beat unknown interfaces, which beat
|
|
39
|
+
* tunnels and bridges; within that, RFC 1918 beats link-local beats CGNAT.
|
|
40
|
+
* Ties keep the OS's order. Names only — no route lookup, so it stays a pure
|
|
41
|
+
* function of `networkInterfaces()`.
|
|
42
|
+
*/
|
|
43
|
+
const pickLanIpv4 = (interfaces = (0, node_os_1.networkInterfaces)()) => {
|
|
44
|
+
let best = null;
|
|
45
|
+
for (const [name, entries] of Object.entries(interfaces)) {
|
|
46
|
+
for (const entry of entries ?? []) {
|
|
47
|
+
if (entry.family !== 'IPv4' || entry.internal || !(0, exports.isPrivateIpv4)(entry.address))
|
|
48
|
+
continue;
|
|
49
|
+
const rank = ifaceRank(name) * 10 + addressRank(entry.address);
|
|
50
|
+
if (best === null || rank < best.rank)
|
|
51
|
+
best = { address: entry.address, rank };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return best?.address ?? null;
|
|
55
|
+
};
|
|
56
|
+
exports.pickLanIpv4 = pickLanIpv4;
|
|
57
|
+
const DEFAULT_WATCH_INTERVAL_MS = 60_000;
|
|
58
|
+
const createLanPublisher = (deps) => {
|
|
59
|
+
const pickHost = deps.pickHost ?? (() => (0, exports.pickLanIpv4)());
|
|
60
|
+
let published = null;
|
|
61
|
+
let watchTimer = null;
|
|
62
|
+
/** One frame. Synchronous, so a watch tick cannot interleave with the
|
|
63
|
+
* forced publish from a socket open the way two in-flight writes could. */
|
|
64
|
+
const announce = (lan) => {
|
|
65
|
+
const sent = deps.send({ type: device_presence_js_1.LISTENER_PRESENCE, data: { lan } });
|
|
66
|
+
if (!sent)
|
|
67
|
+
deps.log('local transfer: endpoint not published — no connection right now, relay until the next open');
|
|
68
|
+
return sent;
|
|
69
|
+
};
|
|
70
|
+
const publish = (port, opts) => {
|
|
71
|
+
try {
|
|
72
|
+
const host = pickHost();
|
|
73
|
+
if (host === null) {
|
|
74
|
+
// The LAN went away (or never existed). Retract a stale endpoint
|
|
75
|
+
// so no sender is pointed at an address we no longer hold.
|
|
76
|
+
if (published !== null && announce(null))
|
|
77
|
+
published = null;
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const unchanged = published !== null && published.host === host && published.port === port;
|
|
81
|
+
if (unchanged && !opts?.force)
|
|
82
|
+
return false;
|
|
83
|
+
const sent = announce({ host, port });
|
|
84
|
+
if (sent)
|
|
85
|
+
published = { host, port };
|
|
86
|
+
return sent;
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
// Called from timers and socket events; a throw here (a host
|
|
90
|
+
// picker that fails) must not take the daemon down.
|
|
91
|
+
deps.log(`local transfer: publish skipped — ${err instanceof Error ? err.message : String(err)}`);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
const clear = () => {
|
|
96
|
+
if (published === null)
|
|
97
|
+
return;
|
|
98
|
+
// Forgotten either way: the socket is closing, and what keeps a
|
|
99
|
+
// sender away from this port after that is `isOnline`, not this field.
|
|
100
|
+
announce(null);
|
|
101
|
+
published = null;
|
|
102
|
+
};
|
|
103
|
+
const stopWatch = () => {
|
|
104
|
+
if (watchTimer)
|
|
105
|
+
clearInterval(watchTimer);
|
|
106
|
+
watchTimer = null;
|
|
107
|
+
};
|
|
108
|
+
const startWatch = (port, intervalMs = DEFAULT_WATCH_INTERVAL_MS) => {
|
|
109
|
+
stopWatch();
|
|
110
|
+
watchTimer = setInterval(() => publish(port), intervalMs);
|
|
111
|
+
watchTimer.unref();
|
|
112
|
+
};
|
|
113
|
+
return { publish, clear, startWatch, stopWatch };
|
|
114
|
+
};
|
|
115
|
+
exports.createLanPublisher = createLanPublisher;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { type NonceRegistry } from './lan-auth.js';
|
|
2
|
+
/**
|
|
3
|
+
* Local transfer (ADR-0013) — the listener's LAN-facing HTTP server.
|
|
4
|
+
*
|
|
5
|
+
* Binds an OS-assigned port on every interface; the port is what
|
|
6
|
+
* `lan-endpoint.ts` publishes. A remote address may hold only a few of the
|
|
7
|
+
* sockets (`LAN_MAX_CONNECTIONS_PER_IP`), so one address cannot starve the rest. Nothing here is reachable from the internet
|
|
8
|
+
* on purpose (the server only ever stores a private IPv4 for it), but a
|
|
9
|
+
* public Wi-Fi is a LAN too, so every route authenticates before it reads
|
|
10
|
+
* a body, and an unauthenticated peer learns nothing: every refusal it can
|
|
11
|
+
* reach is a 401 with an empty body — the same one, whatever the reason.
|
|
12
|
+
*
|
|
13
|
+
* Two routes:
|
|
14
|
+
* GET /hwip/lan/v1/ping — "are you the device I think, and on my LAN?"
|
|
15
|
+
* MAC over an empty body; answers `{deviceId}`.
|
|
16
|
+
* POST /hwip/lan/v1/upload — raw ciphertext body, sealed metadata in
|
|
17
|
+
* `X-Hwip-Lan-Meta`; decrypted as it streams
|
|
18
|
+
* into `<landingDir>/<transferId>/<fileName>`.
|
|
19
|
+
* The push record for it arrives over the
|
|
20
|
+
* WebSocket afterwards and moves the file to
|
|
21
|
+
* the Downloads folder (listener.ts).
|
|
22
|
+
*
|
|
23
|
+
* Order on an upload: header shape → clock → nonce not
|
|
24
|
+
* yet seen → sender on the account → keys → the sealed meta opens. That
|
|
25
|
+
* last step is the pre-body proof: the meta is AES-GCM under a key only
|
|
26
|
+
* the real holder of that device's private key can derive, so a peer that
|
|
27
|
+
* merely knows a registered device id gets no further than 401 either.
|
|
28
|
+
* Only then do the statuses that say anything about this machine appear
|
|
29
|
+
* (409 transfer known, 503 busy, 411/413 length, 400 not wrapped for us),
|
|
30
|
+
* all still before the body. The MAC binds sha256(body), so it is checked
|
|
31
|
+
* when the last byte is in; so is the GCM tag. Either failing removes what
|
|
32
|
+
* was written. The nonce is recorded only once the MAC has verified.
|
|
33
|
+
*/
|
|
34
|
+
export interface LanReceiverOptions {
|
|
35
|
+
log: (msg: string) => void;
|
|
36
|
+
/** A server error after the bind. The socket is unusable from here;
|
|
37
|
+
* the caller retracts the endpoint and turns the feature off. */
|
|
38
|
+
onError?: (err: Error) => void;
|
|
39
|
+
deviceId: () => string;
|
|
40
|
+
/** Registered public key (Base64 SPKI) of a device on this account, or null. */
|
|
41
|
+
lookupSenderPublicKey: (deviceId: string) => Promise<string | null>;
|
|
42
|
+
/** ECDH secret between this device and `peerPublicKeyRaw` (crypto.ts `deriveLanSharedSecret`). */
|
|
43
|
+
deriveSharedSecret: (peerPublicKeyRaw: string) => Promise<Buffer>;
|
|
44
|
+
/** Open this device's `deviceKeyMap` slot (crypto.ts `unwrapDeviceKey`). */
|
|
45
|
+
unwrapFileKey: (entryJson: string, senderPublicKeyRaw: string) => Promise<Buffer>;
|
|
46
|
+
/** Where finished transfers wait for their push record: `~/.hwip/attachments/lan`. */
|
|
47
|
+
landingDir: () => string;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
maxBodyBytes?: number;
|
|
50
|
+
/** Bytes free for an unprivileged writer on the volume of `dir`; `statfsSync` unless a test says. */
|
|
51
|
+
freeBytes?: (dir: string) => number;
|
|
52
|
+
maxInFlight?: number;
|
|
53
|
+
/** Open sockets, of any state, the receiver holds at once. */
|
|
54
|
+
maxConnections?: number;
|
|
55
|
+
/** Open sockets one remote address may hold at once. */
|
|
56
|
+
maxConnectionsPerIp?: number;
|
|
57
|
+
nonces?: NonceRegistry;
|
|
58
|
+
/** A body that sends no bytes for this long is dead. */
|
|
59
|
+
idleTimeoutMs?: number;
|
|
60
|
+
}
|
|
61
|
+
export interface LanReceiver {
|
|
62
|
+
port: number;
|
|
63
|
+
/** Close the socket. Idempotent; resolves once the port is released. */
|
|
64
|
+
stop: () => Promise<void>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* 16 GiB — a long phone video in 4K fits — and the tag each piece of a file
|
|
68
|
+
* that size sealed in chunks adds. No plan's limit applies here: the bytes
|
|
69
|
+
* never reach the relay, and only a device of this account gets past the MAC.
|
|
70
|
+
* What bounds it is the disk (`freeBytes`). A sender sends a file over the
|
|
71
|
+
* relay's ceiling here only to a listener that lists `LAN_LARGE_CAPABILITY`;
|
|
72
|
+
* one from before it takes 1 GiB.
|
|
73
|
+
*/
|
|
74
|
+
export declare const LAN_MAX_FILE_BYTES: number;
|
|
75
|
+
/** What a listener tells senders when it takes `LAN_MAX_FILE_BYTES` over LAN (`capabilities` on its device record). */
|
|
76
|
+
export declare const LAN_LARGE_CAPABILITY = "lan-large-v1";
|
|
77
|
+
/** Room left on the landing volume after a file, so a transfer never fills the disk to its last byte. */
|
|
78
|
+
export declare const LAN_DISK_RESERVE_BYTES: number;
|
|
79
|
+
export declare const LAN_MAX_BODY_BYTES: number;
|
|
80
|
+
export declare const LAN_MAX_IN_FLIGHT = 2;
|
|
81
|
+
/**
|
|
82
|
+
* Beside a landed file, the iv it was sealed under (base64, as the meta gave
|
|
83
|
+
* it). The push record that claims the file comes from the relay; this is what
|
|
84
|
+
* the claim holds it against. A leading dot: no landed file can have this name
|
|
85
|
+
* (`safeFileName` strips them).
|
|
86
|
+
*/
|
|
87
|
+
export declare const LAN_LANDED_IV = ".iv";
|
|
88
|
+
/** The sockets are the daemon's file descriptors, shared with the relay
|
|
89
|
+
* WebSocket: a LAN flood may exhaust local transfer (sends fall back to the
|
|
90
|
+
* relay), never the daemon. */
|
|
91
|
+
export declare const LAN_MAX_CONNECTIONS = 64;
|
|
92
|
+
/**
|
|
93
|
+
* Sockets one remote address may hold. The pool above is shared by the whole
|
|
94
|
+
* LAN, and a host that opens 64 sockets and sends nothing holds every slot for
|
|
95
|
+
* the 5 s `headersTimeout` — and reopens them — which locks every real sender
|
|
96
|
+
* out of the LAN path (they fall back to the relay, nothing worse). A real
|
|
97
|
+
* sender needs a ping and at most `LAN_MAX_IN_FLIGHT` uploads, so a handful per
|
|
98
|
+
* address is plenty, and it takes this many remote addresses to fill the pool —
|
|
99
|
+
* a host with several addresses on the subnet counts as several.
|
|
100
|
+
*
|
|
101
|
+
* Not "bind only the published address": the attacker is on the same LAN and
|
|
102
|
+
* reaches that address as well as any other, so a narrower bind removes nothing
|
|
103
|
+
* from this problem — it only hides the port from other interfaces (a VPN, a
|
|
104
|
+
* docker bridge). It also costs: the publisher re-picks the address every
|
|
105
|
+
* minute as the machine changes networks, and the port is what it publishes, so
|
|
106
|
+
* a socket bound to one address would have to be rebound, and republished under
|
|
107
|
+
* a new port, on every move.
|
|
108
|
+
*/
|
|
109
|
+
export declare const LAN_MAX_CONNECTIONS_PER_IP = 8;
|
|
110
|
+
export declare const LAN_BODY_IDLE_MS = 60000;
|
|
111
|
+
/**
|
|
112
|
+
* Open-socket count per remote address. `admit` before the socket is used,
|
|
113
|
+
* `release` when it closes; an address with no sockets is forgotten, so the map
|
|
114
|
+
* is as large as the number of hosts connected right now and never larger.
|
|
115
|
+
*/
|
|
116
|
+
export declare const createConnectionLimiter: (max: number) => {
|
|
117
|
+
admit: (address: string) => boolean;
|
|
118
|
+
release: (address: string) => void;
|
|
119
|
+
tracked: () => number;
|
|
120
|
+
};
|
|
121
|
+
export declare const startLanReceiver: (opts: LanReceiverOptions) => Promise<LanReceiver>;
|
|
122
|
+
//# sourceMappingURL=lan-receiver.d.ts.map
|