5ed3d2bc5f
First-party booking system on top of Stalwart calendars (no third-party scheduling dependency). Hosts expose public booking pages; visitors pick a slot computed from the host's live Stalwart free/busy, and confirming writes the event to the host's calendar and sends a dezky-branded confirmation with an .ics. platform-api (services/platform-api/src/scheduling): - Schemas: Host, StalwartCredential (AES-256-GCM at rest), AvailabilitySchedule, EventType, Booking, SlotLock (unique (hostId,startUtc) + TTL). - StalwartCalendarModule: JMAP gateway (free/busy via Principal/getAvailability, event create/delete, scheduleAgent=client) + on-behalf app-password provisioning. CredentialCipher for at-rest encryption. - DST-correct slot engine (Luxon) with unit tests; two-layer double-booking guard (atomic SlotLock + live free/busy re-check). - Booking confirm/cancel/reschedule, branded email + .ics via JMAP submission, self-service manage tokens. /api/v1 public + tenant-gated admin routes, per-IP rate limiting. apps/booking: standalone public, whitelabel booking app (booking.dezky.eu) — path-based tenant resolution, per-tenant brand colour, booking + manage flows. apps/portal: admin scheduling page (hosts, event types, availability, bookings with edit/delete + admin cancel/reschedule) and proxy routes. infra: booking dev service in docker-compose; scheduling env vars.
56 lines
2.0 KiB
TypeScript
56 lines
2.0 KiB
TypeScript
import { Injectable } from '@nestjs/common'
|
|
import { ConfigService } from '@nestjs/config'
|
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
|
|
|
|
// AES-256-GCM at-rest encryption for Stalwart host credentials (app passwords).
|
|
// The key comes from SCHEDULING_CREDENTIAL_KEY (64 hex chars = 32 bytes); in
|
|
// production this is sourced from KMS/sealed-secrets. We store ciphertext + iv +
|
|
// authTag separately (all base64) so the GCM auth tag is verified on every open —
|
|
// a tampered ciphertext throws rather than returning garbage. Secrets are NEVER
|
|
// logged: this module deals only in opaque buffers.
|
|
const ALGO = 'aes-256-gcm'
|
|
const IV_BYTES = 12 // GCM standard nonce length
|
|
|
|
export interface SealedSecret {
|
|
encryptedSecret: string // base64 ciphertext
|
|
iv: string // base64 nonce
|
|
authTag: string // base64 GCM tag
|
|
}
|
|
|
|
@Injectable()
|
|
export class CredentialCipher {
|
|
private readonly key: Buffer
|
|
|
|
constructor(config: ConfigService) {
|
|
const hex = config.get<string>('SCHEDULING_CREDENTIAL_KEY') ?? ''
|
|
const key = Buffer.from(hex, 'hex')
|
|
if (key.length !== 32) {
|
|
throw new Error(
|
|
'SCHEDULING_CREDENTIAL_KEY must be 32 bytes (64 hex chars). Generate with: openssl rand -hex 32',
|
|
)
|
|
}
|
|
this.key = key
|
|
}
|
|
|
|
seal(plaintext: string): SealedSecret {
|
|
const iv = randomBytes(IV_BYTES)
|
|
const cipher = createCipheriv(ALGO, this.key, iv)
|
|
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
|
|
return {
|
|
encryptedSecret: enc.toString('base64'),
|
|
iv: iv.toString('base64'),
|
|
authTag: cipher.getAuthTag().toString('base64'),
|
|
}
|
|
}
|
|
|
|
open(sealed: SealedSecret): string {
|
|
const decipher = createDecipheriv(ALGO, this.key, Buffer.from(sealed.iv, 'base64'))
|
|
decipher.setAuthTag(Buffer.from(sealed.authTag, 'base64'))
|
|
const dec = Buffer.concat([
|
|
decipher.update(Buffer.from(sealed.encryptedSecret, 'base64')),
|
|
decipher.final(),
|
|
])
|
|
return dec.toString('utf8')
|
|
}
|
|
}
|