IKHSAN
ENID Baca dalam Bahasa Indonesia

homeio

Public

Forked from doctor-io/homeio

Branch
main
Latest commit
958d74616 October 2026

totp-crypto.ts

Open raw
import "server-only";

import {
  createCipheriv,
  createDecipheriv,
  hkdfSync,
  randomBytes,
} from "node:crypto";
import { serverEnv } from "@/lib/server/env";

const ALGORITHM = "aes-256-gcm" as const;
const KEY_BYTES = 32;
const IV_BYTES = 12;
const TAG_BYTES = 16;
const HKDF_DIGEST = "sha256" as const;
const HKDF_SALT = Buffer.from("homeio.totp.v1.salt", "utf8");
const HKDF_INFO = Buffer.from("homeio.totp.secret-encryption", "utf8");
const PACKED_PART_COUNT = 3;
const PACKED_SEPARATOR = ".";

const INVALID_FORMAT_MESSAGE = "Invalid encrypted secret format";

function decodeExplicitKey(base64: string): Buffer {
  const decoded = Buffer.from(base64, "base64");
  if (decoded.length !== KEY_BYTES) {
    throw new Error(
      `AUTH_TOTP_ENCRYPTION_KEY must decode to ${KEY_BYTES} bytes`,
    );
  }
  return decoded;
}

function deriveKeyFromSessionSecret(sessionSecret: string): Buffer {
  // RFC 5869 HKDF — salt + info give domain separation so a future key
  // derived from the same AUTH_SESSION_SECRET cannot collide with this one.
  const derived = hkdfSync(
    HKDF_DIGEST,
    sessionSecret,
    HKDF_SALT,
    HKDF_INFO,
    KEY_BYTES,
  );
  return Buffer.from(derived);
}

function resolveEncryptionKey(): Buffer {
  const explicit = serverEnv.AUTH_TOTP_ENCRYPTION_KEY?.trim();
  if (explicit) {
    return decodeExplicitKey(explicit);
  }
  return deriveKeyFromSessionSecret(serverEnv.AUTH_SESSION_SECRET);
}

/**
 * Encrypts a TOTP secret (or backup-code JSON) with AES-256-GCM and packs
 * the IV, ciphertext, and authentication tag into a single base64 string
 * of the form `<iv>.<cipher>.<tag>`. A fresh 96-bit IV is generated per
 * call — never reuse an IV with the same key.
 */
export function encryptSecret(plaintext: string): string {
  const key = resolveEncryptionKey();
  const iv = randomBytes(IV_BYTES);
  const cipher = createCipheriv(ALGORITHM, key, iv);

  const ciphertext = Buffer.concat([
    cipher.update(plaintext, "utf8"),
    cipher.final(),
  ]);
  const tag = cipher.getAuthTag();

  return [
    iv.toString("base64"),
    ciphertext.toString("base64"),
    tag.toString("base64"),
  ].join(PACKED_SEPARATOR);
}

/**
 * Reverses {@link encryptSecret}. Throws if the packed format is malformed
 * or if AES-GCM authentication fails (tampering, wrong key, truncated tag).
 */
export function decryptSecret(packed: string): string {
  const parts = packed.split(PACKED_SEPARATOR);
  if (parts.length !== PACKED_PART_COUNT) {
    throw new Error(INVALID_FORMAT_MESSAGE);
  }
  const [ivPart, cipherPart, tagPart] = parts;

  const iv = Buffer.from(ivPart, "base64");
  const ciphertext = Buffer.from(cipherPart, "base64");
  const tag = Buffer.from(tagPart, "base64");

  if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) {
    throw new Error(INVALID_FORMAT_MESSAGE);
  }

  const key = resolveEncryptionKey();
  const decipher = createDecipheriv(ALGORITHM, key, iv);
  decipher.setAuthTag(tag);

  const plaintext = Buffer.concat([
    decipher.update(ciphertext),
    decipher.final(),
  ]);

  return plaintext.toString("utf8");
}
bytes
3,099
lines
108
Type
ts
Latest commit
16 October 2026