fix(tailshare): keep Tailscale auth keys out of persisted preferences
CI / format (pull_request) Successful in 1m50s
CI / lint (pull_request) Successful in 1m53s
CI / install (pull_request) Successful in 6m33s
CI / typetest (pull_request) Successful in 1m39s
CI / typecheck (pull_request) Successful in 2m15s
CI / node-tests (pull_request) Successful in 2m27s
CI / browser-tests (pull_request) Successful in 3m29s
CI / format (pull_request) Successful in 1m50s
CI / lint (pull_request) Successful in 1m53s
CI / install (pull_request) Successful in 6m33s
CI / typetest (pull_request) Successful in 1m39s
CI / typecheck (pull_request) Successful in 2m15s
CI / node-tests (pull_request) Successful in 2m27s
CI / browser-tests (pull_request) Successful in 3m29s
An auth key enrolls a device on the tailnet, and serializing it into tailshare:config alongside the hostname and exit node left it readable by every same-origin script long after the registration flow needed it. Move it to a credential store that lives in the tab's memory and is discarded once the node reaches Running. Persisting it is now an explicit opt-in with its own storage key and a warning, and the presence of that stored copy is the flag, so there is no separate setting to drift out of sync. The raw key no longer reaches the prepare context: consumers see whether one is set, and the input is write-only. parseConfig no longer reads authKey, so a key left in an older tailshare:config is ignored and drops out on the next settings write. There is no migration. Splitting parseConfig and the failure redaction out of IpnContext gives tailshare its first unit tests, covering the storage rules, the opt-in, and that no failure path serializes the key. Reload, worker success, initialization failure, and fallback still need a browser harness. Closes #190 Co-Authored-By: claude-opus-5 <noreply@anthropic.com>
This commit is contained in:
Generated
+2
@@ -10580,6 +10580,7 @@
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.4",
|
||||
"babel-loader": "^9.2.1",
|
||||
@@ -10593,6 +10594,7 @@
|
||||
"sass": "^1.89.0",
|
||||
"sass-loader": "^16.0.5",
|
||||
"style-loader": "^4.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^5.1.4",
|
||||
|
||||
@@ -15,6 +15,12 @@ Tailshare is under active development and does not yet expose the whole stack. T
|
||||
- a Mantine application shell with routing, color scheme switching, and a tailnet drawer;
|
||||
- a placeholder files route.
|
||||
|
||||
## Auth keys
|
||||
|
||||
Tailshare settings live in `localStorage` under `tailshare:config`. A Tailscale auth key does not: it enrolls a device on your tailnet, so it is held in memory for the tab that typed it and discarded once the node registers. A reload before registration means entering it again, which is the intended cost.
|
||||
|
||||
The config modal offers to keep the key in this browser instead. That copy sits in plain text under `tailshare:authKey`, readable by any script on the origin, and survives registration until you forget it. Take the option only if you need unattended re-registration, and prefer the manual login flow when you do not.
|
||||
|
||||
## Intended scope
|
||||
|
||||
The application is intended to expose the features implemented across the repository, and those tracked in the open issues. The planned surface includes:
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"scripts": {
|
||||
"dev": "webpack serve --mode development",
|
||||
"build": "NODE_ENV=production webpack --mode production",
|
||||
"test": "tsx --test --test-force-exit 'src/**/*.test.ts'",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -34,6 +35,7 @@
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.27.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.15",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.4",
|
||||
"babel-loader": "^9.2.1",
|
||||
@@ -47,6 +49,7 @@
|
||||
"sass": "^1.89.0",
|
||||
"sass-loader": "^16.0.5",
|
||||
"style-loader": "^4.0.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.2",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^5.1.4",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { beforeEach, suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
clearAuthKey,
|
||||
getAuthKey,
|
||||
isAuthKeyPersisted,
|
||||
isAuthKeySet,
|
||||
setAuthKey,
|
||||
setAuthKeyPersisted,
|
||||
} from "./authKey"
|
||||
|
||||
class FakeStorage implements Storage {
|
||||
private items = new Map<string, string>()
|
||||
|
||||
get length(): number {
|
||||
return this.items.size
|
||||
}
|
||||
key(index: number): string | null {
|
||||
return [...this.items.keys()][index] ?? null
|
||||
}
|
||||
getItem(key: string): string | null {
|
||||
return this.items.get(key) ?? null
|
||||
}
|
||||
setItem(key: string, value: string): void {
|
||||
this.items.set(key, value)
|
||||
}
|
||||
removeItem(key: string): void {
|
||||
this.items.delete(key)
|
||||
}
|
||||
clear(): void {
|
||||
this.items.clear()
|
||||
}
|
||||
}
|
||||
|
||||
let storage: FakeStorage
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new FakeStorage()
|
||||
globalThis.localStorage = storage
|
||||
clearAuthKey()
|
||||
})
|
||||
|
||||
suite("auth key storage", () => {
|
||||
test("keeps the key in memory and out of storage by default", () => {
|
||||
setAuthKey("tskey-auth-secret")
|
||||
|
||||
assert.equal(getAuthKey(), "tskey-auth-secret")
|
||||
assert.equal(isAuthKeySet(), true)
|
||||
assert.equal(isAuthKeyPersisted(), false)
|
||||
assert.equal(storage.length, 0)
|
||||
})
|
||||
|
||||
test("persists on explicit opt-in and survives a reload", () => {
|
||||
setAuthKey("tskey-auth-secret")
|
||||
setAuthKeyPersisted(true)
|
||||
|
||||
assert.equal(isAuthKeyPersisted(), true)
|
||||
assert.equal(storage.getItem("tailshare:authKey"), "tskey-auth-secret")
|
||||
|
||||
const reloaded = new FakeStorage()
|
||||
reloaded.setItem("tailshare:authKey", "tskey-auth-secret")
|
||||
globalThis.localStorage = reloaded
|
||||
assert.equal(getAuthKey(), "tskey-auth-secret")
|
||||
})
|
||||
|
||||
test("opting out removes the stored copy and keeps the key for this tab", () => {
|
||||
setAuthKey("tskey-auth-secret")
|
||||
setAuthKeyPersisted(true)
|
||||
setAuthKeyPersisted(false)
|
||||
|
||||
assert.equal(isAuthKeyPersisted(), false)
|
||||
assert.equal(storage.length, 0)
|
||||
assert.equal(getAuthKey(), "tskey-auth-secret")
|
||||
})
|
||||
|
||||
test("clearing removes both copies", () => {
|
||||
setAuthKey("tskey-auth-secret")
|
||||
setAuthKeyPersisted(true)
|
||||
clearAuthKey()
|
||||
|
||||
assert.equal(getAuthKey(), "")
|
||||
assert.equal(isAuthKeySet(), false)
|
||||
assert.equal(isAuthKeyPersisted(), false)
|
||||
assert.equal(storage.length, 0)
|
||||
})
|
||||
|
||||
test("replacing a persisted key updates the stored copy", () => {
|
||||
setAuthKey("tskey-auth-first")
|
||||
setAuthKeyPersisted(true)
|
||||
setAuthKey("tskey-auth-second")
|
||||
|
||||
assert.equal(storage.getItem("tailshare:authKey"), "tskey-auth-second")
|
||||
})
|
||||
|
||||
test("survives storage that throws", () => {
|
||||
globalThis.localStorage = new Proxy(storage, {
|
||||
get() {
|
||||
throw new Error("storage disabled")
|
||||
},
|
||||
})
|
||||
|
||||
setAuthKey("tskey-auth-secret")
|
||||
|
||||
assert.equal(getAuthKey(), "tskey-auth-secret")
|
||||
assert.equal(isAuthKeyPersisted(), false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useSyncExternalStore } from "react"
|
||||
|
||||
const STORAGE_KEY = "tailshare:authKey"
|
||||
|
||||
// Auth keys are enrollment credentials, so they live in this tab's memory and
|
||||
// are gone on reload. Persisting one is an explicit opt-in, and the presence of
|
||||
// the stored copy is the flag: there is no separate setting to fall out of sync.
|
||||
let memoryAuthKey = ""
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
function storage(): Storage | null {
|
||||
try {
|
||||
return globalThis.localStorage ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function stored(): string {
|
||||
try {
|
||||
return storage()?.getItem(STORAGE_KEY) ?? ""
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function write(value: string | null): void {
|
||||
try {
|
||||
const target = storage()
|
||||
if (!target) return
|
||||
if (value === null) target.removeItem(STORAGE_KEY)
|
||||
else target.setItem(STORAGE_KEY, value)
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function notify(): void {
|
||||
for (const listener of [...listeners]) listener()
|
||||
}
|
||||
|
||||
export function getAuthKey(): string {
|
||||
return memoryAuthKey || stored()
|
||||
}
|
||||
|
||||
export function isAuthKeySet(): boolean {
|
||||
return !!getAuthKey()
|
||||
}
|
||||
|
||||
export function isAuthKeyPersisted(): boolean {
|
||||
return !!stored()
|
||||
}
|
||||
|
||||
export function setAuthKey(value: string): void {
|
||||
memoryAuthKey = value
|
||||
if (isAuthKeyPersisted()) write(value || null)
|
||||
notify()
|
||||
}
|
||||
|
||||
export function setAuthKeyPersisted(persist: boolean): void {
|
||||
write(persist ? getAuthKey() : null)
|
||||
notify()
|
||||
}
|
||||
|
||||
export function clearAuthKey(): void {
|
||||
memoryAuthKey = ""
|
||||
write(null)
|
||||
notify()
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.storageArea === storage() && (e.key === STORAGE_KEY || e.key === null)) listener()
|
||||
}
|
||||
window.addEventListener("storage", onStorage)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
window.removeEventListener("storage", onStorage)
|
||||
}
|
||||
}
|
||||
|
||||
const serverSnapshot = () => false
|
||||
|
||||
function useAuthKeyStore(get: () => boolean): boolean {
|
||||
return useSyncExternalStore(subscribe, get, serverSnapshot)
|
||||
}
|
||||
|
||||
export function useAuthKeySet(): boolean {
|
||||
return useAuthKeyStore(isAuthKeySet)
|
||||
}
|
||||
|
||||
export function useAuthKeyPersisted(): boolean {
|
||||
return useAuthKeyStore(isAuthKeyPersisted)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { parseConfig } from "./config"
|
||||
|
||||
suite("tailshare configuration", () => {
|
||||
test("drops an auth key left over from an older persisted config", () => {
|
||||
const config = parseConfig(
|
||||
JSON.stringify({ hostname: "alpha", authKey: "tskey-auth-secret", autostart: true }),
|
||||
)
|
||||
|
||||
assert.deepEqual(config, {
|
||||
hostname: "alpha",
|
||||
autostart: true,
|
||||
useWorker: true,
|
||||
fileOps: "memory",
|
||||
})
|
||||
})
|
||||
|
||||
test("a round trip through the config never writes an auth key back", () => {
|
||||
const raw = JSON.stringify({ hostname: "alpha", authKey: "tskey-auth-secret" })
|
||||
const rewritten = JSON.stringify({ ...parseConfig(raw), exitNode: "nodeid" })
|
||||
|
||||
assert.equal(rewritten.includes("tskey-auth-secret"), false)
|
||||
assert.equal(rewritten.includes("authKey"), false)
|
||||
assert.equal(parseConfig(rewritten).hostname, "alpha")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
export type TailshareConfig = {
|
||||
hostname?: string
|
||||
controlURL?: string
|
||||
exitNode?: string
|
||||
autostart?: boolean
|
||||
useWorker?: boolean
|
||||
fileOps?: "memory" | "opfs"
|
||||
}
|
||||
|
||||
// Preferences only. The Tailscale auth key is a credential and lives in
|
||||
// ../authKey, never in the persisted config.
|
||||
export function parseConfig(raw: string): TailshareConfig {
|
||||
const cfg: TailshareConfig = { useWorker: true, fileOps: "memory" }
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return cfg
|
||||
if (typeof parsed.hostname === "string") cfg.hostname = parsed.hostname
|
||||
if (typeof parsed.controlURL === "string") cfg.controlURL = parsed.controlURL
|
||||
if (typeof parsed.exitNode === "string") cfg.exitNode = parsed.exitNode
|
||||
if (typeof parsed.autostart === "boolean") cfg.autostart = parsed.autostart
|
||||
if (typeof parsed.useWorker === "boolean") cfg.useWorker = parsed.useWorker
|
||||
if (parsed.fileOps === "memory" || parsed.fileOps === "opfs") cfg.fileOps = parsed.fileOps
|
||||
} catch {
|
||||
// return defaults
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
@@ -28,14 +28,19 @@ import {
|
||||
} from "react"
|
||||
import { initIPN, InMemoryFileOps, InMemoryState } from "@webnet/tsconnect"
|
||||
import type { IpnClient } from "@webnet/tsconnect"
|
||||
import {
|
||||
serializeError,
|
||||
type SerializedError,
|
||||
useLocalStorage,
|
||||
useSecureContext,
|
||||
useSharedWorkerAvailable,
|
||||
} from "@webnet/react"
|
||||
import { useLocalStorage, useSecureContext, useSharedWorkerAvailable } from "@webnet/react"
|
||||
import wasmUrl from "@webnet/tsconnect/main.wasm"
|
||||
import {
|
||||
clearAuthKey,
|
||||
getAuthKey,
|
||||
isAuthKeyPersisted,
|
||||
setAuthKey,
|
||||
setAuthKeyPersisted,
|
||||
useAuthKeyPersisted,
|
||||
useAuthKeySet,
|
||||
} from "../authKey"
|
||||
import { initializationFailure, type IpnInitializationFailure } from "../ipnFailure"
|
||||
import { parseConfig, type TailshareConfig } from "../config"
|
||||
import type { WorkerConfig } from "@webnet/tsconnect-worker"
|
||||
|
||||
export const IpnStoreContext = createIpnStoreContext()
|
||||
@@ -46,72 +51,18 @@ export const useIpnStore = createUseIpnStore(IpnStoreContext)
|
||||
export const useIpnSelector = createUseIpnSelector(IpnStoreContext)
|
||||
export const useIpnDispatch = createUseIpnDispatch(IpnStoreContext)
|
||||
|
||||
export type TailshareConfig = {
|
||||
hostname?: string
|
||||
controlURL?: string
|
||||
authKey?: string
|
||||
exitNode?: string
|
||||
autostart?: boolean
|
||||
useWorker?: boolean
|
||||
fileOps?: "memory" | "opfs"
|
||||
}
|
||||
|
||||
export type IpnInitializationFailure = {
|
||||
error: SerializedError
|
||||
mode: "worker-with-fallback" | "main-thread"
|
||||
workerError?: SerializedError
|
||||
}
|
||||
|
||||
function redactError(value: string, authKey?: string): string {
|
||||
return value
|
||||
.replaceAll(authKey ?? "\0", "[redacted]")
|
||||
.replace(/(authkey-[\w-]+|authKey[=:]\s*)\S+/gi, "$1[redacted]")
|
||||
}
|
||||
|
||||
function initializationFailure(
|
||||
error: unknown,
|
||||
mode: IpnInitializationFailure["mode"],
|
||||
authKey?: string,
|
||||
): IpnInitializationFailure {
|
||||
const errors =
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"errors" in error &&
|
||||
Array.isArray((error as { errors?: unknown }).errors)
|
||||
? (error as { errors: unknown[] }).errors
|
||||
: undefined
|
||||
if (errors?.length && mode === "worker-with-fallback") {
|
||||
return {
|
||||
error: serializeError(errors.at(-1), (value) => redactError(value, authKey)),
|
||||
mode,
|
||||
workerError: serializeError(errors[0], (value) => redactError(value, authKey)),
|
||||
}
|
||||
}
|
||||
return { error: serializeError(error, (value) => redactError(value, authKey)), mode }
|
||||
}
|
||||
|
||||
function parseConfig(raw: string): TailshareConfig {
|
||||
const cfg: TailshareConfig = { useWorker: true, fileOps: "memory" }
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return cfg
|
||||
if (typeof parsed.hostname === "string") cfg.hostname = parsed.hostname
|
||||
if (typeof parsed.controlURL === "string") cfg.controlURL = parsed.controlURL
|
||||
if (typeof parsed.authKey === "string") cfg.authKey = parsed.authKey
|
||||
if (typeof parsed.exitNode === "string") cfg.exitNode = parsed.exitNode
|
||||
if (typeof parsed.autostart === "boolean") cfg.autostart = parsed.autostart
|
||||
if (typeof parsed.useWorker === "boolean") cfg.useWorker = parsed.useWorker
|
||||
if (parsed.fileOps === "memory" || parsed.fileOps === "opfs") cfg.fileOps = parsed.fileOps
|
||||
} catch {
|
||||
// return defaults
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
export type { IpnInitializationFailure, TailshareConfig }
|
||||
|
||||
export const IpnPrepareContext = createContext<{
|
||||
config: TailshareConfig
|
||||
configAtBuild: TailshareConfig | null
|
||||
setConfig: (patch: Partial<TailshareConfig>) => void
|
||||
authKeySet: boolean
|
||||
authKeyPersisted: boolean
|
||||
authKeyChanged: boolean
|
||||
setAuthKey: (value: string) => void
|
||||
setAuthKeyPersisted: (persist: boolean) => void
|
||||
forgetAuthKey: () => void
|
||||
willBuild: boolean
|
||||
build: () => void
|
||||
initializationFailure: IpnInitializationFailure | null
|
||||
@@ -139,6 +90,28 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
[setConfigRaw],
|
||||
)
|
||||
|
||||
const authKeySet = useAuthKeySet()
|
||||
const authKeyPersisted = useAuthKeyPersisted()
|
||||
// Held in a ref rather than state: discarding the key must not change the
|
||||
// identity of workerConfig, or useBuildIpnWorker would tear the live
|
||||
// connection down and reconnect the moment the node starts running.
|
||||
const authKeyAtBuildRef = useRef("")
|
||||
const [authKeyChanged, setAuthKeyChanged] = useState(false)
|
||||
|
||||
const changeAuthKey = useCallback((value: string) => {
|
||||
setAuthKey(value)
|
||||
setAuthKeyChanged(value !== authKeyAtBuildRef.current)
|
||||
}, [])
|
||||
const forgetAuthKey = useCallback(() => {
|
||||
clearAuthKey()
|
||||
setAuthKeyChanged(!!authKeyAtBuildRef.current)
|
||||
}, [])
|
||||
const discardAuthKey = useCallback(() => {
|
||||
if (isAuthKeyPersisted()) return
|
||||
clearAuthKey()
|
||||
authKeyAtBuildRef.current = ""
|
||||
}, [])
|
||||
|
||||
const [willBuild, setWillBuild] = useState(false)
|
||||
const [failure, setFailure] = useState<IpnInitializationFailure | null>(null)
|
||||
|
||||
@@ -147,6 +120,8 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
const build = useCallback(() => {
|
||||
if (!secureContext) return
|
||||
configAtBuildRef.current = config
|
||||
authKeyAtBuildRef.current = getAuthKey()
|
||||
setAuthKeyChanged(false)
|
||||
setFailure(null)
|
||||
setWillBuild(true)
|
||||
}, [config, secureContext])
|
||||
@@ -172,12 +147,12 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
wasmUrl: wasmUrl as string,
|
||||
hostname: buildConfig.hostname || undefined,
|
||||
controlURL: buildConfig.controlURL || undefined,
|
||||
authKey: buildConfig.authKey || undefined,
|
||||
authKey: authKeyAtBuildRef.current || undefined,
|
||||
stateStorage: "indexeddb",
|
||||
fileOps: buildConfig.fileOps === "opfs",
|
||||
fileOpsDir: "taildrop",
|
||||
}),
|
||||
[buildConfig.hostname, buildConfig.controlURL, buildConfig.authKey, buildConfig.fileOps],
|
||||
[buildConfig.hostname, buildConfig.controlURL, buildConfig.fileOps, willBuild],
|
||||
)
|
||||
|
||||
const workerClient = useBuildIpnWorker(
|
||||
@@ -185,7 +160,7 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
workerConfig,
|
||||
null,
|
||||
(error) => {
|
||||
setFailure(initializationFailure(error, "worker-with-fallback", buildConfig.authKey))
|
||||
setFailure(initializationFailure(error, "worker-with-fallback", authKeyAtBuildRef.current))
|
||||
setWillBuild(false)
|
||||
},
|
||||
)
|
||||
@@ -197,11 +172,11 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
initIPN(wasmUrl).then(
|
||||
(builder) => setIpnBuilder(() => builder),
|
||||
(error) => {
|
||||
setFailure(initializationFailure(error, "main-thread", buildConfig.authKey))
|
||||
setFailure(initializationFailure(error, "main-thread", authKeyAtBuildRef.current))
|
||||
setWillBuild(false)
|
||||
},
|
||||
)
|
||||
}, [willBuild, useWorker, buildConfig.authKey])
|
||||
}, [willBuild, useWorker])
|
||||
|
||||
const builderParams = useMemo(() => {
|
||||
const params: Parameters<Exclude<typeof ipnBuilder, null>>[0] = {}
|
||||
@@ -209,9 +184,9 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
params.stateStorage = new InMemoryState()
|
||||
if (buildConfig.hostname) params.hostname = buildConfig.hostname
|
||||
if (buildConfig.controlURL) params.controlURL = buildConfig.controlURL
|
||||
if (buildConfig.authKey) params.authKey = buildConfig.authKey
|
||||
if (authKeyAtBuildRef.current) params.authKey = authKeyAtBuildRef.current
|
||||
return params
|
||||
}, [buildConfig.hostname, buildConfig.controlURL, buildConfig.authKey])
|
||||
}, [buildConfig.hostname, buildConfig.controlURL, willBuild])
|
||||
|
||||
const mainIpn = useBuildIpn(
|
||||
localStore,
|
||||
@@ -220,7 +195,7 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
null,
|
||||
(error) => {
|
||||
setIpnBuilder(null)
|
||||
setFailure(initializationFailure(error, "main-thread", buildConfig.authKey))
|
||||
setFailure(initializationFailure(error, "main-thread", authKeyAtBuildRef.current))
|
||||
setWillBuild(false)
|
||||
},
|
||||
)
|
||||
@@ -239,6 +214,12 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
config,
|
||||
configAtBuild,
|
||||
setConfig,
|
||||
authKeySet,
|
||||
authKeyPersisted,
|
||||
authKeyChanged,
|
||||
setAuthKey: changeAuthKey,
|
||||
setAuthKeyPersisted,
|
||||
forgetAuthKey,
|
||||
willBuild,
|
||||
build,
|
||||
initializationFailure: failure,
|
||||
@@ -250,6 +231,11 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
config,
|
||||
configAtBuild,
|
||||
setConfig,
|
||||
authKeySet,
|
||||
authKeyPersisted,
|
||||
authKeyChanged,
|
||||
changeAuthKey,
|
||||
forgetAuthKey,
|
||||
willBuild,
|
||||
build,
|
||||
failure,
|
||||
@@ -265,6 +251,7 @@ export function IpnProvider({ children }: { children: ReactNode }) {
|
||||
<>
|
||||
<AutoLogin ipn={ipn} />
|
||||
<AutoExitNode ipn={ipn} config={config} setConfig={setConfig} />
|
||||
<DiscardAuthKey discard={discardAuthKey} />
|
||||
</>
|
||||
)}
|
||||
<AutoInit build={build} config={config} setConfig={setConfig} />
|
||||
@@ -283,6 +270,17 @@ function AutoLogin({ ipn }: { ipn: IpnClient }) {
|
||||
return null
|
||||
}
|
||||
|
||||
function DiscardAuthKey({ discard }: { discard: () => void }) {
|
||||
const state = useIpnSelector(getIpnState)
|
||||
|
||||
const discardOnce = useEffectEvent(discard)
|
||||
useEffect(() => {
|
||||
if (state === "Running") discardOnce()
|
||||
}, [state])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function AutoExitNode({
|
||||
ipn,
|
||||
config,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { suite, test } from "node:test"
|
||||
import assert from "node:assert/strict"
|
||||
import { initializationFailure } from "./ipnFailure"
|
||||
|
||||
const KEY = "tskey-auth-kabcdef1234-secret"
|
||||
|
||||
suite("IPN initialization failures", () => {
|
||||
test("redacts the active key from the message", () => {
|
||||
const failure = initializationFailure(
|
||||
new Error(`register failed for ${KEY}`),
|
||||
"main-thread",
|
||||
KEY,
|
||||
)
|
||||
|
||||
assert.equal(failure.error.message.includes(KEY), false)
|
||||
assert.match(failure.error.message, /\[redacted\]/)
|
||||
})
|
||||
|
||||
test("redacts key-shaped text even without the active key", () => {
|
||||
const failure = initializationFailure(
|
||||
new Error(`control rejected authKey: ${KEY}`),
|
||||
"main-thread",
|
||||
)
|
||||
|
||||
assert.equal(failure.error.message.includes(KEY), false)
|
||||
})
|
||||
|
||||
test("redacts both errors of a worker-then-fallback failure", () => {
|
||||
const aggregate = new Error("both failed") as Error & { errors: unknown[] }
|
||||
aggregate.errors = [new Error(`worker: ${KEY}`), new Error(`main thread: ${KEY}`)]
|
||||
|
||||
const failure = initializationFailure(aggregate, "worker-with-fallback", KEY)
|
||||
|
||||
assert.equal(failure.error.message.includes(KEY), false)
|
||||
assert.equal(failure.workerError?.message.includes(KEY), false)
|
||||
assert.equal(JSON.stringify(failure).includes(KEY), false)
|
||||
})
|
||||
|
||||
test("an empty key does not redact every position in the message", () => {
|
||||
const failure = initializationFailure(new Error("wasm fetch failed"), "main-thread", "")
|
||||
|
||||
assert.equal(failure.error.message, "wasm fetch failed")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { serializeError, type SerializedError } from "@webnet/react"
|
||||
|
||||
export type IpnInitializationFailure = {
|
||||
error: SerializedError
|
||||
mode: "worker-with-fallback" | "main-thread"
|
||||
workerError?: SerializedError
|
||||
}
|
||||
|
||||
function redactError(value: string, authKey?: string): string {
|
||||
return value
|
||||
.replaceAll(authKey || "\0", "[redacted]")
|
||||
.replace(/(authkey-[\w-]+|authKey[=:]\s*)\S+/gi, "$1[redacted]")
|
||||
}
|
||||
|
||||
export function initializationFailure(
|
||||
error: unknown,
|
||||
mode: IpnInitializationFailure["mode"],
|
||||
authKey?: string,
|
||||
): IpnInitializationFailure {
|
||||
const errors =
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"errors" in error &&
|
||||
Array.isArray((error as { errors?: unknown }).errors)
|
||||
? (error as { errors: unknown[] }).errors
|
||||
: undefined
|
||||
if (errors?.length && mode === "worker-with-fallback") {
|
||||
return {
|
||||
error: serializeError(errors.at(-1), (value) => redactError(value, authKey)),
|
||||
mode,
|
||||
workerError: serializeError(errors[0], (value) => redactError(value, authKey)),
|
||||
}
|
||||
}
|
||||
return { error: serializeError(error, (value) => redactError(value, authKey)), mode }
|
||||
}
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
getWaitingFiles,
|
||||
} from "@webnet/tsconnect-redux"
|
||||
import { IpnContext, IpnPrepareContext, useIpnSelector } from "../contexts/IpnContext"
|
||||
import { use } from "react"
|
||||
import { use, useEffect, useState } from "react"
|
||||
import styles from "./tailscale.scss"
|
||||
import { CopyIcon, DownloadIcon, TrashIcon } from "@phosphor-icons/react"
|
||||
import { useDisclosure } from "@mantine/hooks"
|
||||
@@ -126,6 +126,7 @@ function TailscaleState() {
|
||||
|
||||
function TailscaleConfig() {
|
||||
const [opened, { open, close }] = useDisclosure()
|
||||
const [authKeyDraft, setAuthKeyDraft] = useState("")
|
||||
const exitNode = useIpnSelector(getExitNode)
|
||||
const peers = useIpnSelector(getPeers)
|
||||
const dispatch = useTailshareDispatch()
|
||||
@@ -136,12 +137,17 @@ function TailscaleConfig() {
|
||||
built &&
|
||||
(config.hostname !== built.hostname ||
|
||||
config.controlURL !== built.controlURL ||
|
||||
config.authKey !== built.authKey ||
|
||||
prepare.authKeyChanged ||
|
||||
!!config.useWorker !== !!built.useWorker ||
|
||||
config.fileOps !== built.fileOps)
|
||||
|
||||
const opfsWithoutWorker = config.fileOps === "opfs" && (!workerAvailable || !config.useWorker)
|
||||
|
||||
// Drop the typed copy once the key is discarded on registration or forgotten.
|
||||
useEffect(() => {
|
||||
if (!prepare.authKeySet) setAuthKeyDraft("")
|
||||
}, [prepare.authKeySet])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group>
|
||||
@@ -178,18 +184,46 @@ function TailscaleConfig() {
|
||||
<TextInput
|
||||
mt="xs"
|
||||
withAsterisk
|
||||
type="password"
|
||||
label={<>Pregenerated authkey</>}
|
||||
description={
|
||||
// TODO: make this more clear that this has security implications
|
||||
<>
|
||||
This is a soft credential that will be stored in the browser - only set this if you
|
||||
must, logging in manually is more secure
|
||||
An authkey enrolls a new device on your tailnet. Tailshare keeps it in memory for this
|
||||
tab only and discards it once the node registers. Logging in manually is safer.
|
||||
</>
|
||||
}
|
||||
placeholder="(Manual login flow)"
|
||||
value={config.authKey ?? ""}
|
||||
onChange={(e) => setConfig({ authKey: e.target.value || undefined })}
|
||||
placeholder={prepare.authKeySet ? "(Set, hidden)" : "(Manual login flow)"}
|
||||
value={authKeyDraft}
|
||||
onChange={(e) => {
|
||||
setAuthKeyDraft(e.target.value)
|
||||
prepare.setAuthKey(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<Group mt="xs" justify="space-between" align="center">
|
||||
<Checkbox
|
||||
disabled={!prepare.authKeySet}
|
||||
label="Keep this authkey in this browser"
|
||||
checked={prepare.authKeyPersisted}
|
||||
onChange={(e) => prepare.setAuthKeyPersisted(e.target.checked)}
|
||||
/>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={!prepare.authKeySet}
|
||||
onClick={() => {
|
||||
setAuthKeyDraft("")
|
||||
prepare.forgetAuthKey()
|
||||
}}
|
||||
>
|
||||
Forget authkey
|
||||
</Button>
|
||||
</Group>
|
||||
{prepare.authKeyPersisted && (
|
||||
<Text mt="xs" c="orange" size="sm">
|
||||
The authkey is stored in plain text on this origin and survives registration. Any script
|
||||
running here can read it. Forget it once the node is enrolled.
|
||||
</Text>
|
||||
)}
|
||||
<TextInput
|
||||
mt="xs"
|
||||
withAsterisk
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"isolatedModules": true,
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
"check-submodule": {
|
||||
"cache": false
|
||||
},
|
||||
"@webnet/tailshare#test": {
|
||||
"dependsOn": ["^build"]
|
||||
},
|
||||
"@webnet/tsconnect#typecheck": {
|
||||
"dependsOn": ["build-go", "^build"]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user