[verified] refactor: harden operations and redesign device console
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
export function canonicalQuery(input = '') {
|
||||
const params = new URLSearchParams(String(input).replace(/^\?/, ''))
|
||||
params.sort()
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
export function canonicalProxyTarget(input) {
|
||||
if (typeof input !== 'string' || !input.startsWith('/') || input.startsWith('//')) throw new Error('invalid confirmation path')
|
||||
let url
|
||||
try { url = new URL(input, 'http://confirmation.local') } catch { throw new Error('invalid confirmation path') }
|
||||
if (url.origin !== 'http://confirmation.local' || url.hash) throw new Error('invalid confirmation path')
|
||||
return `${url.pathname}${canonicalQuery(url.search) ? `?${canonicalQuery(url.search)}` : ''}`
|
||||
}
|
||||
|
||||
export function bodyDigest(body) {
|
||||
const stable = value => {
|
||||
if (value === undefined) return 'undefined'
|
||||
if (Buffer.isBuffer(value)) return value
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
||||
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`
|
||||
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stable(value[key])}`).join(',')}}`
|
||||
}
|
||||
const value = stable(body)
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
export class ConfirmationStore {
|
||||
#entries = new Map()
|
||||
constructor({ ttlMs = 30_000, maxEntries = 1024, now = Date.now } = {}) { this.ttlMs = ttlMs; this.maxEntries = maxEntries; this.now = now }
|
||||
prepare(binding) {
|
||||
const now = this.now()
|
||||
for (const [key, entry] of this.#entries) if (entry.expiresAt <= now) this.#entries.delete(key)
|
||||
while (this.#entries.size >= this.maxEntries) this.#entries.delete(this.#entries.keys().next().value)
|
||||
const token = randomBytes(32).toString('base64url')
|
||||
const expiresAt = now + this.ttlMs
|
||||
this.#entries.set(token, { ...binding, expiresAt })
|
||||
return { token, expiresAt }
|
||||
}
|
||||
consume(token, binding) {
|
||||
if (typeof token !== 'string' || token.length < 16) return false
|
||||
const entry = this.#entries.get(token)
|
||||
if (!entry) return false
|
||||
this.#entries.delete(token)
|
||||
if (entry.expiresAt <= this.now()) return false
|
||||
const expected = Buffer.from(JSON.stringify(binding))
|
||||
const actual = Buffer.from(JSON.stringify(Object.fromEntries(Object.keys(binding).map(key => [key, entry[key]]))))
|
||||
const matches = expected.length === actual.length && timingSafeEqual(expected, actual)
|
||||
return matches
|
||||
}
|
||||
}
|
||||
|
||||
export function confirmationBinding({ instance, revision, method, target, body }) {
|
||||
return {
|
||||
instanceId: instance.id,
|
||||
revision,
|
||||
origin: new URL(instance.url).origin,
|
||||
method: String(method).toUpperCase(),
|
||||
target: canonicalProxyTarget(target),
|
||||
bodyDigest: bodyDigest(body),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user