| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425 |
- import {
- createCipheriv,
- createDecipheriv,
- createHash,
- randomBytes,
- randomUUID,
- timingSafeEqual,
- } from 'node:crypto'
- import {
- mkdirSync,
- readFileSync,
- renameSync,
- writeFileSync,
- } from 'node:fs'
- import { join } from 'node:path'
- import { HttpError } from './errors.js'
- const PAIRING_TTL_MS = 2 * 60 * 1_000
- const MAX_PENDING_PAIRINGS = 32
- const MAX_PAIRED_DEVICES = 64
- const TOKEN_BYTES = 32
- export const NODE_PAIRING_PROTOCOL_VERSION = 1
- export const DEFAULT_NODE_SCOPES = [
- 'agents.read',
- 'history.read',
- 'sessions.execute',
- 'groups.read',
- 'groups.execute',
- ] as const
- export type NodeScope = typeof DEFAULT_NODE_SCOPES[number]
- interface PersistedDevice {
- id: string
- name: string
- tokenHash: string
- encryptedCookie: string
- workspaceOwner?: string
- scopes: NodeScope[]
- createdAt: number
- lastUsedAt: number
- }
- interface PersistedPairingState {
- version: 1
- nodeID: string
- devices: PersistedDevice[]
- }
- interface PendingPairing {
- id: string
- secretHash: Buffer
- cookieHeader: string
- workspaceOwner?: string
- scopes: NodeScope[]
- createdAt: number
- expiresAt: number
- claimedDeviceID?: string
- }
- export interface PairingSession {
- id: string
- secret: string
- nodeID: string
- fingerprint: string
- scopes: NodeScope[]
- createdAt: number
- expiresAt: number
- }
- export interface PairedDeviceSummary {
- id: string
- name: string
- scopes: NodeScope[]
- createdAt: number
- lastUsedAt: number
- }
- export interface PairingClaim {
- existingDeviceID?: string
- existingToken?: string
- pairingID: string
- secret: string
- deviceName: string
- }
- export interface PairingClaimResult {
- device: PairedDeviceSummary
- token: string
- nodeID: string
- fingerprint: string
- scopes: NodeScope[]
- }
- function hashSecret(value: string): Buffer {
- return createHash('sha256').update(value, 'utf8').digest()
- }
- function canonicalDeviceName(value: string): string {
- const normalized = value.trim().replace(/\s+/g, ' ')
- if (!normalized || normalized.length > 100 || /[\u0000-\u001f\u007f]/.test(normalized)) {
- throw new HttpError(400, 'Device name is invalid', 'invalid_device_name')
- }
- return normalized
- }
- function canonicalPairingID(value: string): string {
- const normalized = value.trim().toLowerCase()
- 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)) {
- throw new HttpError(400, 'Pairing ID is invalid', 'invalid_pairing')
- }
- return normalized
- }
- function canonicalDeviceID(value: string): string {
- const normalized = value.trim().toLowerCase()
- 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)) {
- throw new HttpError(400, 'Device ID is invalid', 'invalid_device')
- }
- return normalized
- }
- function parseScopes(value: readonly string[] | undefined): NodeScope[] {
- const requested = value ?? [...DEFAULT_NODE_SCOPES]
- const allowed = new Set<string>(DEFAULT_NODE_SCOPES)
- const result: NodeScope[] = []
- for (const scope of requested) {
- if (!allowed.has(scope)) {
- throw new HttpError(400, `Unsupported node scope: ${scope}`, 'invalid_scope')
- }
- if (!result.includes(scope as NodeScope)) result.push(scope as NodeScope)
- }
- if (result.length === 0) throw new HttpError(400, 'At least one node scope is required', 'invalid_scope')
- return result
- }
- function persistentSecret(home: string): Buffer {
- mkdirSync(home, { recursive: true, mode: 0o700 })
- const path = join(home, 'node-pairing-secret.bin')
- try {
- const existing = readFileSync(path)
- if (existing.byteLength !== 32) throw new Error('Node pairing secret has an invalid length')
- return existing
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
- }
- const generated = randomBytes(32)
- try {
- writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
- return generated
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
- throw error
- }
- }
- export class NodePairingStore {
- readonly #home: string
- readonly #secret: Buffer
- readonly #statePath: string
- readonly #clock: () => number
- readonly #pending = new Map<string, PendingPairing>()
- #state: PersistedPairingState
- constructor(home: string, clock: () => number = Date.now) {
- this.#home = home
- this.#secret = persistentSecret(home)
- this.#statePath = join(home, 'paired-devices.json')
- this.#clock = clock
- this.#state = this.#load()
- }
- get nodeID(): string { return this.#state.nodeID }
- get fingerprint(): string {
- return createHash('sha256')
- .update(this.#state.nodeID, 'utf8')
- .update(this.#secret)
- .digest('base64url')
- }
- create(cookieHeader: string | undefined, requestedScopes?: readonly string[], workspaceOwner?: string): PairingSession {
- const cookies = cookieHeader?.trim() ?? ''
- if (!cookies) {
- throw new HttpError(401, 'An authenticated Hermes session is required', 'authentication_required')
- }
- this.#prunePending()
- while (this.#pending.size >= MAX_PENDING_PAIRINGS) {
- const oldest = this.#pending.keys().next().value as string | undefined
- if (!oldest) break
- this.#pending.delete(oldest)
- }
- const id = randomUUID().toLowerCase()
- const secret = randomBytes(TOKEN_BYTES).toString('base64url')
- const now = this.#clock()
- const pending: PendingPairing = {
- id,
- secretHash: hashSecret(`${id}:${secret}`),
- cookieHeader: cookies,
- workspaceOwner,
- scopes: parseScopes(requestedScopes),
- createdAt: now,
- expiresAt: now + PAIRING_TTL_MS,
- }
- this.#pending.set(id, pending)
- return {
- id,
- secret,
- nodeID: this.nodeID,
- fingerprint: this.fingerprint,
- scopes: [...pending.scopes],
- createdAt: now,
- expiresAt: pending.expiresAt,
- }
- }
- status(pairingID: string): { state: 'pending' | 'claimed' | 'expired'; deviceID?: string } {
- const id = canonicalPairingID(pairingID)
- const pending = this.#pending.get(id)
- if (!pending || pending.expiresAt <= this.#clock()) {
- this.#pending.delete(id)
- return { state: 'expired' }
- }
- return pending.claimedDeviceID
- ? { state: 'claimed', deviceID: pending.claimedDeviceID }
- : { state: 'pending' }
- }
- claim(input: PairingClaim): PairingClaimResult {
- this.#prunePending()
- const id = canonicalPairingID(input.pairingID)
- const pending = this.#pending.get(id)
- if (!pending || pending.expiresAt <= this.#clock()) {
- this.#pending.delete(id)
- throw new HttpError(410, 'Pairing code expired', 'pairing_expired')
- }
- if (pending.claimedDeviceID) {
- throw new HttpError(409, 'Pairing code was already used', 'pairing_already_used')
- }
- const supplied = hashSecret(`${id}:${input.secret}`)
- if (supplied.byteLength !== pending.secretHash.byteLength
- || !timingSafeEqual(supplied, pending.secretHash)) {
- throw new HttpError(401, 'Pairing secret is invalid', 'invalid_pairing_secret')
- }
- const existing = input.existingDeviceID
- ? (this.authorize(input.existingDeviceID,input.existingToken ?? ''), this.#state.devices.find(d=>d.id===input.existingDeviceID)) : undefined
- if (!existing && this.#state.devices.length >= MAX_PAIRED_DEVICES) {
- throw new HttpError(409, 'Paired device limit reached', 'paired_device_limit')
- }
- const now = this.#clock()
- const token = existing ? input.existingToken! : randomBytes(TOKEN_BYTES).toString('base64url')
- const device: PersistedDevice = {
- id: existing?.id ?? randomUUID().toLowerCase(),
- name: canonicalDeviceName(input.deviceName),
- tokenHash: hashSecret(token).toString('base64url'),
- encryptedCookie: this.#encrypt(pending.cookieHeader),
- workspaceOwner: pending.workspaceOwner,
- scopes: [...pending.scopes],
- createdAt: existing?.createdAt ?? now,
- lastUsedAt: now,
- }
- if (existing) Object.assign(existing,device)
- else this.#state.devices.push(device)
- pending.claimedDeviceID = device.id
- pending.cookieHeader = ''
- this.#save()
- return {
- device: this.#summary(device),
- token,
- nodeID: this.nodeID,
- fingerprint: this.fingerprint,
- scopes: [...device.scopes],
- }
- }
- list(): PairedDeviceSummary[] {
- return this.#state.devices
- .map((device) => this.#summary(device))
- .sort((left, right) => right.lastUsedAt - left.lastUsedAt)
- }
- hasDevice(deviceID: string): boolean {
- try {
- const id = canonicalDeviceID(deviceID)
- return this.#state.devices.some((device) => device.id === id)
- } catch {
- return false
- }
- }
- delegatedCookies(deviceID: string): string {
- const id = canonicalDeviceID(deviceID)
- const device = this.#state.devices.find((candidate) => candidate.id === id)
- if (!device) throw new HttpError(404, 'Paired device was not found', 'paired_device_not_found')
- return this.#decrypt(device.encryptedCookie)
- }
- revoke(deviceID: string): boolean {
- const id = canonicalDeviceID(deviceID)
- const before = this.#state.devices.length
- this.#state.devices = this.#state.devices.filter((device) => device.id !== id)
- if (this.#state.devices.length === before) return false
- this.#save()
- return true
- }
- authorize(deviceID: string, token: string, requiredScope?: NodeScope): string {
- const id = canonicalDeviceID(deviceID)
- const normalizedToken = token.trim()
- if (!normalizedToken || normalizedToken.length > 512) {
- throw new HttpError(401, 'Paired device token is missing', 'paired_device_unauthorized')
- }
- const device = this.#state.devices.find((candidate) => candidate.id === id)
- if (!device) throw new HttpError(401, 'Paired device is not recognized', 'paired_device_unauthorized')
- const expected = Buffer.from(device.tokenHash, 'base64url')
- const supplied = hashSecret(normalizedToken)
- if (expected.byteLength !== supplied.byteLength || !timingSafeEqual(expected, supplied)) {
- throw new HttpError(401, 'Paired device token is invalid', 'paired_device_unauthorized')
- }
- if (requiredScope && !device.scopes.includes(requiredScope)) {
- throw new HttpError(403, `Paired device lacks ${requiredScope}`, 'paired_device_forbidden')
- }
- device.lastUsedAt = this.#clock()
- this.#save()
- return this.#decrypt(device.encryptedCookie)
- }
- workspaceOwner(deviceID: string, token: string, scope: NodeScope): string {
- this.authorize(deviceID, token, scope)
- const owner = this.#state.devices.find(device => device.id === deviceID)?.workspaceOwner
- if (!owner) throw new HttpError(403, '请升级远端并重新扫码授权,以读取远端 Bot 模式机器人', 'workspace_agent_pairing_required')
- return owner
- }
- updateCookies(deviceID: string, cookieHeader: string | undefined): void {
- const id = canonicalDeviceID(deviceID)
- const device = this.#state.devices.find((candidate) => candidate.id === id)
- if (!device || !cookieHeader?.trim()) return
- device.encryptedCookie = this.#encrypt(cookieHeader.trim())
- device.lastUsedAt = this.#clock()
- this.#save()
- }
- #summary(device: PersistedDevice): PairedDeviceSummary {
- return {
- id: device.id,
- name: device.name,
- scopes: [...device.scopes],
- createdAt: device.createdAt,
- lastUsedAt: device.lastUsedAt,
- }
- }
- #prunePending(): void {
- const now = this.#clock()
- for (const [id, pending] of this.#pending) {
- if (pending.expiresAt <= now) this.#pending.delete(id)
- }
- }
- #encrypt(value: string): string {
- const iv = randomBytes(12)
- const cipher = createCipheriv('aes-256-gcm', this.#secret, iv)
- const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
- return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url')
- }
- #decrypt(value: string): string {
- try {
- const encoded = Buffer.from(value, 'base64url')
- if (encoded.byteLength < 29) throw new Error('encrypted cookie is truncated')
- const iv = encoded.subarray(0, 12)
- const tag = encoded.subarray(12, 28)
- const ciphertext = encoded.subarray(28)
- const decipher = createDecipheriv('aes-256-gcm', this.#secret, iv)
- decipher.setAuthTag(tag)
- return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
- } catch {
- throw new HttpError(401, 'Paired device credentials are unavailable', 'paired_device_credentials_invalid')
- }
- }
- #load(): PersistedPairingState {
- try {
- const parsed = JSON.parse(readFileSync(this.#statePath, 'utf8')) as Partial<PersistedPairingState>
- if (parsed.version !== 1
- || typeof parsed.nodeID !== 'string'
- || !Array.isArray(parsed.devices)) {
- throw new Error('paired device state is invalid')
- }
- return {
- version: 1,
- nodeID: canonicalDeviceID(parsed.nodeID),
- devices: parsed.devices as PersistedDevice[],
- }
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
- const state: PersistedPairingState = {
- version: 1,
- nodeID: randomUUID().toLowerCase(),
- devices: [],
- }
- this.#state = state
- this.#save()
- return state
- }
- }
- #save(): void {
- mkdirSync(this.#home, { recursive: true, mode: 0o700 })
- const temporary = `${this.#statePath}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`
- writeFileSync(temporary, `${JSON.stringify(this.#state, null, 2)}\n`, {
- mode: 0o600,
- flag: 'wx',
- })
- renameSync(temporary, this.#statePath)
- }
- }
- export function bearerToken(value: string | undefined): string {
- const match = value?.match(/^Bearer ([A-Za-z0-9_-]{20,512})$/)
- if (!match) throw new HttpError(401, 'Paired device bearer token is required', 'paired_device_unauthorized')
- return match[1]!
- }
|