pairing.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. import {
  2. createCipheriv,
  3. createDecipheriv,
  4. createHash,
  5. randomBytes,
  6. randomUUID,
  7. timingSafeEqual,
  8. } from 'node:crypto'
  9. import {
  10. mkdirSync,
  11. readFileSync,
  12. renameSync,
  13. writeFileSync,
  14. } from 'node:fs'
  15. import { join } from 'node:path'
  16. import { HttpError } from './errors.js'
  17. const PAIRING_TTL_MS = 2 * 60 * 1_000
  18. const MAX_PENDING_PAIRINGS = 32
  19. const MAX_PAIRED_DEVICES = 64
  20. const TOKEN_BYTES = 32
  21. export const NODE_PAIRING_PROTOCOL_VERSION = 1
  22. export const DEFAULT_NODE_SCOPES = [
  23. 'agents.read',
  24. 'history.read',
  25. 'sessions.execute',
  26. 'groups.read',
  27. 'groups.execute',
  28. ] as const
  29. export type NodeScope = typeof DEFAULT_NODE_SCOPES[number]
  30. interface PersistedDevice {
  31. id: string
  32. name: string
  33. tokenHash: string
  34. encryptedCookie: string
  35. workspaceOwner?: string
  36. scopes: NodeScope[]
  37. createdAt: number
  38. lastUsedAt: number
  39. }
  40. interface PersistedPairingState {
  41. version: 1
  42. nodeID: string
  43. devices: PersistedDevice[]
  44. }
  45. interface PendingPairing {
  46. id: string
  47. secretHash: Buffer
  48. cookieHeader: string
  49. workspaceOwner?: string
  50. scopes: NodeScope[]
  51. createdAt: number
  52. expiresAt: number
  53. claimedDeviceID?: string
  54. }
  55. export interface PairingSession {
  56. id: string
  57. secret: string
  58. nodeID: string
  59. fingerprint: string
  60. scopes: NodeScope[]
  61. createdAt: number
  62. expiresAt: number
  63. }
  64. export interface PairedDeviceSummary {
  65. id: string
  66. name: string
  67. scopes: NodeScope[]
  68. createdAt: number
  69. lastUsedAt: number
  70. }
  71. export interface PairingClaim {
  72. existingDeviceID?: string
  73. existingToken?: string
  74. pairingID: string
  75. secret: string
  76. deviceName: string
  77. }
  78. export interface PairingClaimResult {
  79. device: PairedDeviceSummary
  80. token: string
  81. nodeID: string
  82. fingerprint: string
  83. scopes: NodeScope[]
  84. }
  85. function hashSecret(value: string): Buffer {
  86. return createHash('sha256').update(value, 'utf8').digest()
  87. }
  88. function canonicalDeviceName(value: string): string {
  89. const normalized = value.trim().replace(/\s+/g, ' ')
  90. if (!normalized || normalized.length > 100 || /[\u0000-\u001f\u007f]/.test(normalized)) {
  91. throw new HttpError(400, 'Device name is invalid', 'invalid_device_name')
  92. }
  93. return normalized
  94. }
  95. function canonicalPairingID(value: string): string {
  96. const normalized = value.trim().toLowerCase()
  97. if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
  98. throw new HttpError(400, 'Pairing ID is invalid', 'invalid_pairing')
  99. }
  100. return normalized
  101. }
  102. function canonicalDeviceID(value: string): string {
  103. const normalized = value.trim().toLowerCase()
  104. if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(normalized)) {
  105. throw new HttpError(400, 'Device ID is invalid', 'invalid_device')
  106. }
  107. return normalized
  108. }
  109. function parseScopes(value: readonly string[] | undefined): NodeScope[] {
  110. const requested = value ?? [...DEFAULT_NODE_SCOPES]
  111. const allowed = new Set<string>(DEFAULT_NODE_SCOPES)
  112. const result: NodeScope[] = []
  113. for (const scope of requested) {
  114. if (!allowed.has(scope)) {
  115. throw new HttpError(400, `Unsupported node scope: ${scope}`, 'invalid_scope')
  116. }
  117. if (!result.includes(scope as NodeScope)) result.push(scope as NodeScope)
  118. }
  119. if (result.length === 0) throw new HttpError(400, 'At least one node scope is required', 'invalid_scope')
  120. return result
  121. }
  122. function persistentSecret(home: string): Buffer {
  123. mkdirSync(home, { recursive: true, mode: 0o700 })
  124. const path = join(home, 'node-pairing-secret.bin')
  125. try {
  126. const existing = readFileSync(path)
  127. if (existing.byteLength !== 32) throw new Error('Node pairing secret has an invalid length')
  128. return existing
  129. } catch (error) {
  130. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  131. }
  132. const generated = randomBytes(32)
  133. try {
  134. writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
  135. return generated
  136. } catch (error) {
  137. if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
  138. throw error
  139. }
  140. }
  141. export class NodePairingStore {
  142. readonly #home: string
  143. readonly #secret: Buffer
  144. readonly #statePath: string
  145. readonly #clock: () => number
  146. readonly #pending = new Map<string, PendingPairing>()
  147. #state: PersistedPairingState
  148. constructor(home: string, clock: () => number = Date.now) {
  149. this.#home = home
  150. this.#secret = persistentSecret(home)
  151. this.#statePath = join(home, 'paired-devices.json')
  152. this.#clock = clock
  153. this.#state = this.#load()
  154. }
  155. get nodeID(): string { return this.#state.nodeID }
  156. get fingerprint(): string {
  157. return createHash('sha256')
  158. .update(this.#state.nodeID, 'utf8')
  159. .update(this.#secret)
  160. .digest('base64url')
  161. }
  162. create(cookieHeader: string | undefined, requestedScopes?: readonly string[], workspaceOwner?: string): PairingSession {
  163. const cookies = cookieHeader?.trim() ?? ''
  164. if (!cookies) {
  165. throw new HttpError(401, 'An authenticated Hermes session is required', 'authentication_required')
  166. }
  167. this.#prunePending()
  168. while (this.#pending.size >= MAX_PENDING_PAIRINGS) {
  169. const oldest = this.#pending.keys().next().value as string | undefined
  170. if (!oldest) break
  171. this.#pending.delete(oldest)
  172. }
  173. const id = randomUUID().toLowerCase()
  174. const secret = randomBytes(TOKEN_BYTES).toString('base64url')
  175. const now = this.#clock()
  176. const pending: PendingPairing = {
  177. id,
  178. secretHash: hashSecret(`${id}:${secret}`),
  179. cookieHeader: cookies,
  180. workspaceOwner,
  181. scopes: parseScopes(requestedScopes),
  182. createdAt: now,
  183. expiresAt: now + PAIRING_TTL_MS,
  184. }
  185. this.#pending.set(id, pending)
  186. return {
  187. id,
  188. secret,
  189. nodeID: this.nodeID,
  190. fingerprint: this.fingerprint,
  191. scopes: [...pending.scopes],
  192. createdAt: now,
  193. expiresAt: pending.expiresAt,
  194. }
  195. }
  196. status(pairingID: string): { state: 'pending' | 'claimed' | 'expired'; deviceID?: string } {
  197. const id = canonicalPairingID(pairingID)
  198. const pending = this.#pending.get(id)
  199. if (!pending || pending.expiresAt <= this.#clock()) {
  200. this.#pending.delete(id)
  201. return { state: 'expired' }
  202. }
  203. return pending.claimedDeviceID
  204. ? { state: 'claimed', deviceID: pending.claimedDeviceID }
  205. : { state: 'pending' }
  206. }
  207. claim(input: PairingClaim): PairingClaimResult {
  208. this.#prunePending()
  209. const id = canonicalPairingID(input.pairingID)
  210. const pending = this.#pending.get(id)
  211. if (!pending || pending.expiresAt <= this.#clock()) {
  212. this.#pending.delete(id)
  213. throw new HttpError(410, 'Pairing code expired', 'pairing_expired')
  214. }
  215. if (pending.claimedDeviceID) {
  216. throw new HttpError(409, 'Pairing code was already used', 'pairing_already_used')
  217. }
  218. const supplied = hashSecret(`${id}:${input.secret}`)
  219. if (supplied.byteLength !== pending.secretHash.byteLength
  220. || !timingSafeEqual(supplied, pending.secretHash)) {
  221. throw new HttpError(401, 'Pairing secret is invalid', 'invalid_pairing_secret')
  222. }
  223. const existing = input.existingDeviceID
  224. ? (this.authorize(input.existingDeviceID,input.existingToken ?? ''), this.#state.devices.find(d=>d.id===input.existingDeviceID)) : undefined
  225. if (!existing && this.#state.devices.length >= MAX_PAIRED_DEVICES) {
  226. throw new HttpError(409, 'Paired device limit reached', 'paired_device_limit')
  227. }
  228. const now = this.#clock()
  229. const token = existing ? input.existingToken! : randomBytes(TOKEN_BYTES).toString('base64url')
  230. const device: PersistedDevice = {
  231. id: existing?.id ?? randomUUID().toLowerCase(),
  232. name: canonicalDeviceName(input.deviceName),
  233. tokenHash: hashSecret(token).toString('base64url'),
  234. encryptedCookie: this.#encrypt(pending.cookieHeader),
  235. workspaceOwner: pending.workspaceOwner,
  236. scopes: [...pending.scopes],
  237. createdAt: existing?.createdAt ?? now,
  238. lastUsedAt: now,
  239. }
  240. if (existing) Object.assign(existing,device)
  241. else this.#state.devices.push(device)
  242. pending.claimedDeviceID = device.id
  243. pending.cookieHeader = ''
  244. this.#save()
  245. return {
  246. device: this.#summary(device),
  247. token,
  248. nodeID: this.nodeID,
  249. fingerprint: this.fingerprint,
  250. scopes: [...device.scopes],
  251. }
  252. }
  253. list(): PairedDeviceSummary[] {
  254. return this.#state.devices
  255. .map((device) => this.#summary(device))
  256. .sort((left, right) => right.lastUsedAt - left.lastUsedAt)
  257. }
  258. hasDevice(deviceID: string): boolean {
  259. try {
  260. const id = canonicalDeviceID(deviceID)
  261. return this.#state.devices.some((device) => device.id === id)
  262. } catch {
  263. return false
  264. }
  265. }
  266. delegatedCookies(deviceID: string): string {
  267. const id = canonicalDeviceID(deviceID)
  268. const device = this.#state.devices.find((candidate) => candidate.id === id)
  269. if (!device) throw new HttpError(404, 'Paired device was not found', 'paired_device_not_found')
  270. return this.#decrypt(device.encryptedCookie)
  271. }
  272. revoke(deviceID: string): boolean {
  273. const id = canonicalDeviceID(deviceID)
  274. const before = this.#state.devices.length
  275. this.#state.devices = this.#state.devices.filter((device) => device.id !== id)
  276. if (this.#state.devices.length === before) return false
  277. this.#save()
  278. return true
  279. }
  280. authorize(deviceID: string, token: string, requiredScope?: NodeScope): string {
  281. const id = canonicalDeviceID(deviceID)
  282. const normalizedToken = token.trim()
  283. if (!normalizedToken || normalizedToken.length > 512) {
  284. throw new HttpError(401, 'Paired device token is missing', 'paired_device_unauthorized')
  285. }
  286. const device = this.#state.devices.find((candidate) => candidate.id === id)
  287. if (!device) throw new HttpError(401, 'Paired device is not recognized', 'paired_device_unauthorized')
  288. const expected = Buffer.from(device.tokenHash, 'base64url')
  289. const supplied = hashSecret(normalizedToken)
  290. if (expected.byteLength !== supplied.byteLength || !timingSafeEqual(expected, supplied)) {
  291. throw new HttpError(401, 'Paired device token is invalid', 'paired_device_unauthorized')
  292. }
  293. if (requiredScope && !device.scopes.includes(requiredScope)) {
  294. throw new HttpError(403, `Paired device lacks ${requiredScope}`, 'paired_device_forbidden')
  295. }
  296. device.lastUsedAt = this.#clock()
  297. this.#save()
  298. return this.#decrypt(device.encryptedCookie)
  299. }
  300. workspaceOwner(deviceID: string, token: string, scope: NodeScope): string {
  301. this.authorize(deviceID, token, scope)
  302. const owner = this.#state.devices.find(device => device.id === deviceID)?.workspaceOwner
  303. if (!owner) throw new HttpError(403, '请升级远端并重新扫码授权,以读取远端 Bot 模式机器人', 'workspace_agent_pairing_required')
  304. return owner
  305. }
  306. updateCookies(deviceID: string, cookieHeader: string | undefined): void {
  307. const id = canonicalDeviceID(deviceID)
  308. const device = this.#state.devices.find((candidate) => candidate.id === id)
  309. if (!device || !cookieHeader?.trim()) return
  310. device.encryptedCookie = this.#encrypt(cookieHeader.trim())
  311. device.lastUsedAt = this.#clock()
  312. this.#save()
  313. }
  314. #summary(device: PersistedDevice): PairedDeviceSummary {
  315. return {
  316. id: device.id,
  317. name: device.name,
  318. scopes: [...device.scopes],
  319. createdAt: device.createdAt,
  320. lastUsedAt: device.lastUsedAt,
  321. }
  322. }
  323. #prunePending(): void {
  324. const now = this.#clock()
  325. for (const [id, pending] of this.#pending) {
  326. if (pending.expiresAt <= now) this.#pending.delete(id)
  327. }
  328. }
  329. #encrypt(value: string): string {
  330. const iv = randomBytes(12)
  331. const cipher = createCipheriv('aes-256-gcm', this.#secret, iv)
  332. const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
  333. return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url')
  334. }
  335. #decrypt(value: string): string {
  336. try {
  337. const encoded = Buffer.from(value, 'base64url')
  338. if (encoded.byteLength < 29) throw new Error('encrypted cookie is truncated')
  339. const iv = encoded.subarray(0, 12)
  340. const tag = encoded.subarray(12, 28)
  341. const ciphertext = encoded.subarray(28)
  342. const decipher = createDecipheriv('aes-256-gcm', this.#secret, iv)
  343. decipher.setAuthTag(tag)
  344. return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
  345. } catch {
  346. throw new HttpError(401, 'Paired device credentials are unavailable', 'paired_device_credentials_invalid')
  347. }
  348. }
  349. #load(): PersistedPairingState {
  350. try {
  351. const parsed = JSON.parse(readFileSync(this.#statePath, 'utf8')) as Partial<PersistedPairingState>
  352. if (parsed.version !== 1
  353. || typeof parsed.nodeID !== 'string'
  354. || !Array.isArray(parsed.devices)) {
  355. throw new Error('paired device state is invalid')
  356. }
  357. return {
  358. version: 1,
  359. nodeID: canonicalDeviceID(parsed.nodeID),
  360. devices: parsed.devices as PersistedDevice[],
  361. }
  362. } catch (error) {
  363. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  364. const state: PersistedPairingState = {
  365. version: 1,
  366. nodeID: randomUUID().toLowerCase(),
  367. devices: [],
  368. }
  369. this.#state = state
  370. this.#save()
  371. return state
  372. }
  373. }
  374. #save(): void {
  375. mkdirSync(this.#home, { recursive: true, mode: 0o700 })
  376. const temporary = `${this.#statePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
  377. writeFileSync(temporary, `${JSON.stringify(this.#state, null, 2)}\n`, {
  378. mode: 0o600,
  379. flag: 'wx',
  380. })
  381. renameSync(temporary, this.#statePath)
  382. }
  383. }
  384. export function bearerToken(value: string | undefined): string {
  385. const match = value?.match(/^Bearer ([A-Za-z0-9_-]{20,512})$/)
  386. if (!match) throw new HttpError(401, 'Paired device bearer token is required', 'paired_device_unauthorized')
  387. return match[1]!
  388. }