rekey-integrations.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. /**
  2. * Re-seals every integration connection under a new vault key.
  3. *
  4. * Credentials are sealed with INTEGRATIONS_ENCRYPTION_KEY, or with a key
  5. * derived from BETTER_AUTH_SECRET when that is unset. Changing either without
  6. * re-sealing leaves every connection unreadable: calendars stop syncing and
  7. * the messaging channels fall back to the settings rows from before the move,
  8. * logging an error on every send. This script is the way to change a key.
  9. *
  10. * Give it the key the rows are sealed with today and the key they should be
  11. * sealed with from now on. Either may be given as the 64-hex value or as the
  12. * BETTER_AUTH_SECRET it was derived from:
  13. *
  14. * OLD_INTEGRATIONS_ENCRYPTION_KEY=<hex> NEW_INTEGRATIONS_ENCRYPTION_KEY=<hex> \
  15. * npx tsx scripts/rekey-integrations.ts # report only
  16. * ... npx tsx scripts/rekey-integrations.ts --write # re-seal
  17. *
  18. * OLD_BETTER_AUTH_SECRET=<secret> NEW_INTEGRATIONS_ENCRYPTION_KEY=<hex> ...
  19. *
  20. * Run it while the app still holds the old key, then switch the app to the
  21. * new key and restart. Rows that the old key cannot open are reported and
  22. * left as they are.
  23. */
  24. import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
  25. import { db } from '@/lib/db'
  26. const VERSION = 'v1'
  27. function keyFrom(prefix: 'OLD' | 'NEW'): Buffer {
  28. const explicit = process.env[`${prefix}_INTEGRATIONS_ENCRYPTION_KEY`]?.trim()
  29. if (explicit) {
  30. if (!/^[0-9a-f]{64}$/i.test(explicit)) {
  31. throw new Error(`${prefix}_INTEGRATIONS_ENCRYPTION_KEY must be 64 hex characters`)
  32. }
  33. return Buffer.from(explicit, 'hex')
  34. }
  35. const secret = process.env[`${prefix}_BETTER_AUTH_SECRET`]
  36. if (!secret) {
  37. throw new Error(
  38. `Set ${prefix}_INTEGRATIONS_ENCRYPTION_KEY or ${prefix}_BETTER_AUTH_SECRET (the same derivation the app uses)`
  39. )
  40. }
  41. return Buffer.from(hkdfSync('sha256', secret, 'torqvoice', 'integrations-vault', 32))
  42. }
  43. function open(sealed: string, key: Buffer): string {
  44. const [version, ivB64, tagB64, dataB64] = sealed.split('.')
  45. if (version !== VERSION || !ivB64 || !tagB64 || !dataB64) {
  46. throw new Error('Unrecognised credential format')
  47. }
  48. const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(ivB64, 'base64url'))
  49. decipher.setAuthTag(Buffer.from(tagB64, 'base64url'))
  50. return Buffer.concat([
  51. decipher.update(Buffer.from(dataB64, 'base64url')),
  52. decipher.final(),
  53. ]).toString('utf8')
  54. }
  55. function seal(plaintext: string, key: Buffer): string {
  56. const iv = randomBytes(12)
  57. const cipher = createCipheriv('aes-256-gcm', key, iv)
  58. const encrypted = Buffer.concat([cipher.update(Buffer.from(plaintext, 'utf8')), cipher.final()])
  59. return [
  60. VERSION,
  61. iv.toString('base64url'),
  62. cipher.getAuthTag().toString('base64url'),
  63. encrypted.toString('base64url'),
  64. ].join('.')
  65. }
  66. async function main(): Promise<void> {
  67. const write = process.argv.includes('--write')
  68. const oldKey = keyFrom('OLD')
  69. const newKey = keyFrom('NEW')
  70. if (oldKey.equals(newKey)) {
  71. console.log('Old and new keys are the same; nothing to do.')
  72. return
  73. }
  74. const rows = await db.integrationConnection.findMany({
  75. where: { credentials: { not: null } },
  76. select: { id: true, connectorId: true, organizationId: true, credentials: true },
  77. })
  78. let resealed = 0
  79. let unreadable = 0
  80. for (const row of rows) {
  81. if (!row.credentials) continue
  82. let plaintext: string
  83. try {
  84. plaintext = open(row.credentials, oldKey)
  85. } catch {
  86. console.error(
  87. `cannot open ${row.connectorId} connection ${row.id} of ${row.organizationId} with the old key; left as is`
  88. )
  89. unreadable++
  90. continue
  91. }
  92. if (write) {
  93. await db.integrationConnection.update({
  94. where: { id: row.id },
  95. data: { credentials: seal(plaintext, newKey) },
  96. })
  97. }
  98. resealed++
  99. }
  100. console.log(
  101. `${write ? 'Re-sealed' : 'Would re-seal'} ${resealed} of ${rows.length} connections; ${unreadable} unreadable with the old key.`
  102. )
  103. if (!write) console.log('Run again with --write to make the changes.')
  104. if (unreadable > 0) process.exitCode = 1
  105. }
  106. main()
  107. .catch((err) => {
  108. console.error(err)
  109. process.exitCode = 1
  110. })
  111. .finally(() => db.$disconnect())