All files / web/src/lib secret-box.ts

100% Statements 60/60
100% Branches 13/13
100% Functions 3/3
100% Lines 60/60

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 612x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 86x 86x 86x 1x 1x 85x 86x 1x 1x 84x 84x 2x 2x 2x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 2x 2x 2x 36x 36x 36x 4x 4x 32x 32x 32x 32x 32x 32x 32x 32x 36x 3x 3x 36x  
/**
 * Symmetric encryption-at-rest for small secrets (print-service bearer tokens,
 * webhook ring secrets). Server-only: uses node:crypto, keyed by SECRET_BOX_KEY.
 *
 * Sealed format (all segments base64url): `sb1:<iv>:<authTag>:<ciphertext>`
 * AES-256-GCM — authenticated, so tampering or a wrong key fails loudly on open.
 *
 * Key management: SECRET_BOX_KEY must decode to exactly 32 bytes
 * (`openssl rand -base64 32`). Rotating the key orphans everything sealed under
 * the old one — for print connections the recovery is simply re-pairing.
 */
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
 
const VERSION = 'sb1'
 
function loadKey(): Buffer {
  const raw = process.env.SECRET_BOX_KEY
  if (!raw) {
    throw new Error('SECRET_BOX_KEY is not set — generate one with `openssl rand -base64 32`')
  }
  const key = Buffer.from(raw, 'base64')
  if (key.length !== 32) {
    throw new Error('SECRET_BOX_KEY must decode to exactly 32 bytes (`openssl rand -base64 32`)')
  }
  return key
}
 
/** Encrypt a UTF-8 string. Fresh random IV per call — sealing the same value twice yields different outputs. */
export function seal(plaintext: string): string {
  const iv = randomBytes(12)
  const cipher = createCipheriv('aes-256-gcm', loadKey(), iv)
  const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
  const tag = cipher.getAuthTag()
  return [
    VERSION,
    iv.toString('base64url'),
    tag.toString('base64url'),
    ciphertext.toString('base64url'),
  ].join(':')
}
 
/** Decrypt a sealed string. Throws on unknown format, wrong key, or tampered data — never returns garbage. */
export function open(sealed: string): string {
  const segments = sealed.split(':')
  // The ciphertext segment may be empty (GCM authenticates empty plaintext); iv and tag cannot.
  if (segments.length !== 4 || segments[0] !== VERSION || !segments[1] || !segments[2]) {
    throw new Error('secret-box: unrecognized sealed format')
  }
  const [, ivB64, tagB64, ctB64] = segments
  const decipher = createDecipheriv('aes-256-gcm', loadKey(), Buffer.from(ivB64, 'base64url'))
  decipher.setAuthTag(Buffer.from(tagB64, 'base64url'))
  try {
    return Buffer.concat([
      decipher.update(Buffer.from(ctB64, 'base64url')),
      decipher.final(),
    ]).toString('utf8')
  } catch {
    throw new Error('secret-box: decryption failed (wrong key or tampered data)')
  }
}