Files
orca/cloud/apps/push/src/host-challenge-store.ts
Jinwoo Hong eb2f2d52ae feat(cloud): native push gateway and dedicated infrastructure (1/3) (#19912)
* refactor(cloud): share PostgreSQL schema startup between services

* feat(cloud): add durable native push notification gateway

* infra(push): define dedicated gateway resources and operational checks

* fix(push): bound cross-host admission and simplify gateway configuration

* fix(push): validate deploy configuration and preserve topic-error registrations
2026-09-10 17:59:46 -04:00

136 lines
5.1 KiB
TypeScript

import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
import {
buildPushHostChallengePlaintext,
buildPushHostProofMacInput,
buildPushHostProofTranscript,
PUSH_LIMITS
} from '@orca-cloud/push-contract'
import nacl from 'tweetnacl'
import { decodeCanonicalBase64 } from './canonical-base64.js'
import { deriveHostFingerprint } from './host-fingerprint.js'
import type { PushDatabase } from './push-database.js'
export type IssuedPushChallenge = {
challengeId: string
gatewayEphemeralPublicKeyB64: string
nonceB64: string
ciphertextB64: string
expiresAt: number
hostFingerprint: string
}
export type PushProofVerification =
| { ok: true; hostFingerprint: string }
| { ok: false; reason: 'unknown_challenge' | 'already_consumed' | 'expired' | 'proof_mismatch' }
function sha256(value: Uint8Array): string {
return createHash('sha256').update(value).digest('base64url')
}
function equalDigest(left: string, right: string): boolean {
const leftBytes = Buffer.from(left)
const rightBytes = Buffer.from(right)
return leftBytes.length === rightBytes.length && timingSafeEqual(leftBytes, rightBytes)
}
export class PushHostChallengeStore {
constructor(
private readonly database: PushDatabase,
private readonly gatewayOrigin: string,
private readonly now: () => number = Date.now
) {}
async issue(hostPublicKeyB64: string): Promise<IssuedPushChallenge | null> {
const hostPublicKey = decodeCanonicalBase64(hostPublicKeyB64, 32)
if (!hostPublicKey) return null
const hostFingerprint = deriveHostFingerprint(hostPublicKey)
const ephemeral = nacl.box.keyPair()
const challengeNonce = randomBytes(nacl.box.nonceLength)
const challengeSecret = randomBytes(32)
const challengeId = randomUUID()
const issuedAt = this.now()
const expiresAt = issuedAt + PUSH_LIMITS.challengeTtlMs
const transcript = buildPushHostProofTranscript({
gatewayOrigin: this.gatewayOrigin,
gatewayEphemeralPublicKey: ephemeral.publicKey,
challengeNonce,
challengeId,
issuedAt,
expiresAt,
hostFingerprint,
hostPublicKey
})
const ciphertext = nacl.box(
buildPushHostChallengePlaintext(transcript, challengeSecret),
challengeNonce,
hostPublicKey,
ephemeral.secretKey
)
const expectedProof = createHmac('sha256', challengeSecret)
.update(buildPushHostProofMacInput(transcript))
.digest()
await this.database.query(
`INSERT INTO push_challenges
(challenge_id, host_fingerprint, secret_hash, expires_at,
consumed_at)
VALUES (?, ?, ?, ?, NULL)`,
[
challengeId,
hostFingerprint,
// The stored digest is of the ack the secret produces, never of the
// secret itself: a database reader must not be able to forge a proof.
sha256(expectedProof),
expiresAt
]
)
return {
challengeId,
gatewayEphemeralPublicKeyB64: Buffer.from(ephemeral.publicKey).toString('base64'),
nonceB64: Buffer.from(challengeNonce).toString('base64'),
ciphertextB64: Buffer.from(ciphertext).toString('base64'),
expiresAt,
hostFingerprint
}
}
async verify(challengeId: string, proofB64: string): Promise<PushProofVerification> {
const proof = decodeCanonicalBase64(proofB64, 32)
return await this.database.transaction<PushProofVerification>(async (transaction) => {
const [row] = await transaction.query(
`SELECT host_fingerprint, secret_hash, expires_at, consumed_at
FROM push_challenges WHERE challenge_id = ?`,
[challengeId]
)
if (!row) return { ok: false, reason: 'unknown_challenge' }
if (row.consumed_at !== null && row.consumed_at !== undefined) {
return { ok: false, reason: 'already_consumed' }
}
const now = this.now()
// No skew allowance here: the gateway set expires_at from this same clock.
// The tolerance belongs to the host, which validates a foreign timestamp.
if (now > Number(row.expires_at)) return { ok: false, reason: 'expired' }
if (!proof || !equalDigest(sha256(proof), String(row.secret_hash))) {
return { ok: false, reason: 'proof_mismatch' }
}
// Consume under the same predicate the read used, so two concurrent
// proofs for one challenge cannot both mint a session.
const [consumed] = await transaction.query(
'UPDATE push_challenges SET consumed_at = ? WHERE challenge_id = ? AND consumed_at IS NULL',
[now, challengeId]
)
if (Number(consumed?.changes ?? 0) !== 1) return { ok: false, reason: 'already_consumed' }
return { ok: true, hostFingerprint: String(row.host_fingerprint) }
})
}
// Rows outlive the expiry check by the skew tolerance so a late proof reads
// as 'expired' rather than as an unknown challenge.
async pruneExpired(): Promise<number> {
const cutoff = this.now() - PUSH_LIMITS.clockSkewToleranceMs
const [result] = await this.database.query('DELETE FROM push_challenges WHERE expires_at < ?', [
cutoff
])
return Number(result?.changes ?? 0)
}
}