Security · Layer 6

Credentials and API security.

This layer protects two credentials: the GitHub token and the local console token. They are protected differently. The GitHub token is encrypted at rest; the console token is stored in an owner-only file rather than encrypted. Both are compared in constant time and validated before use. The source below is quoted verbatim from the public mcp-server repository.

← Back to the security overview

The threat

A token stored in plaintext on disk is readable by any other process with access to it, and a token written to a log is exposed wherever that log is retained. Two failure modes are subtler: a naive string comparison leaks token contents through timing, and a Unicode-normalized comparison without a format gate can admit a homoglyph. This layer addresses all four before a token is used.

How a console request is authenticated

Console authentication is feature-gated. It is enforced only when DOLLHOUSE_WEB_AUTH_ENABLED is set. During the current Phase 1 rollout it is off by default; when it is off, the authentication middleware is a deliberate no-op and the request passes through unmodified. The flow described below applies when authentication is enabled. Until then, the local console is a localhost-trust surface.

When authentication is enabled, the presented token is normalized, format-gated to strict hexadecimal, and then compared in constant time. Inexpensive rejections run first; the timing-safe comparison is the final step.

flowchart TD
  R[Request arrives] --> EN{Auth enabled? DOLLHOUSE_WEB_AUTH_ENABLED}
  EN -- "no: Phase 1 default" --> PASS[Middleware is a no-op: request passes through]
  EN -- yes --> EX{Bearer header or SSE token query?}
  EX -- neither --> D401[401: no token]
  EX -- yes --> NF[NFC-normalize the presented token]
  NF --> HEX{Matches strict 64-hex format?}
  HEX -- no --> D401
  HEX -- yes --> LEN{Length equals a stored token?}
  LEN -- no --> D401
  LEN -- yes --> TS[crypto.timingSafeEqual compare]
  TS -- no match --> D401
  TS -- match --> OK[Authenticated, lastUsedAt updated]
  classDef deny fill:#b91c1c,stroke:#7f1d1d,color:#fff;
  classDef allow fill:#15803d,stroke:#14532d,color:#fff;
  class D401 deny;
  class OK allow;

The GitHub token is encrypted at rest, never logged in full

A GitHub token is validated by format, then encrypted with AES-256-GCM under a key derived from PBKDF2 with a random per-token salt. The stored blob carries its own salt, IV, and auth tag, and the file is written owner-only.

From src/security/tokenManager.ts

private deriveKey(passphrase: string, salt: Buffer): Buffer {
  return crypto.pbkdf2Sync(passphrase, salt, TokenManager.ITERATIONS, TokenManager.KEY_LENGTH, 'sha256');
}

// storeGitHubToken():
const salt = crypto.randomBytes(TokenManager.SALT_LENGTH);
const iv = crypto.randomBytes(TokenManager.IV_LENGTH);
const key = this.deriveKey(this.getPassphrase(), salt);

const cipher = crypto.createCipheriv(TokenManager.ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(validation.normalizedContent, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();

// Storage format: salt + iv + tag + encrypted
const stored = Buffer.concat([salt, iv, tag, encrypted]);
await this.fileOperations.writeFile(tokenPath, stored.toString('base64'), { source: 'TokenManager.storeGitHubToken' });
await this.fileOperations.chmod(tokenPath, 0o600, { source: 'TokenManager.storeGitHubToken' });

Any token that reaches a log or error string is redacted by pattern, for every GitHub token format:

createSafeErrorMessage(error: string, token?: string): string {
  let safeMessage = error
    .replaceAll(/\bghp_\S+/g, '[REDACTED_PAT]')
    .replaceAll(/\bgithub_pat_\S+/g, '[REDACTED_FINE_PAT]')
    .replaceAll(/\bghs_\S+/g, '[REDACTED_INSTALL]')
    .replaceAll(/\bgho_\S+/g, '[REDACTED_OAUTH]')
    .replaceAll(/\bgh[a-z]_\S+/gi, '[REDACTED_TOKEN]');  // catch any other gh*_ pattern
  return safeMessage;
}

The console token is owner-only, not encrypted

The console token is written as a plaintext token field in ~/.dollhouse/run/console-token.auth.json. It is not encrypted; the protection is an owner-only file mode. This is a documented design decision with a stated limit: a process running as the same user can read the file, and on Windows the mode is not OS-enforced — the implementation logs an explicit warning in that case.

From src/web/console/consoleToken.ts

/** File mode for the token file — owner read/write only. */
const TOKEN_FILE_MODE = 0o600;

private async write(file: ConsoleTokenFile): Promise<void> {
  await mkdir(RUN_DIR, { recursive: true });
  const tmpFile = `${this.filePath}.${process.pid}.tmp`;
  await writeFile(tmpFile, JSON.stringify(file, null, 2), 'utf8');
  await chmod(tmpFile, TOKEN_FILE_MODE);   // owner-only; not encrypted
  await rename(tmpFile, this.filePath);
  this.warnIfWindowsPermissions();
}

The resulting threat model is asymmetric: AES-256-GCM protects the GitHub token even against another local process, while the console token relies on filesystem permissions and on the console being feature-gated and localhost-bound.

The console compare is constant-time and Unicode-safe

The strict hex format check runs before timingSafeEqual, so Unicode abuse never reaches the comparison, and a length mismatch short-circuits so the timing-safe path only ever compares equal-length buffers.

From src/web/console/consoleToken.ts and authMiddleware.ts

const TOKEN_FORMAT = /^[0-9a-f]{64}$/;

verify(presented: string): ConsoleTokenEntry | null {
  if (!this.data || !presented) return null;

  // DMCP-SEC-004: NFC-normalize and strict-format-check before any compare.
  // This blocks Unicode abuse from reaching timingSafeEqual.
  const normalized = UnicodeValidator.normalize(presented).normalizedContent;
  if (!TOKEN_FORMAT.test(normalized)) return null;

  const presentedBuf = Buffer.from(normalized, 'utf8');
  for (const entry of this.data.tokens) {
    const storedBuf = this.tokenBuffers.get(entry.id);
    if (!storedBuf || storedBuf.length !== presentedBuf.length) continue;
    if (timingSafeEqual(presentedBuf, storedBuf)) {
      entry.lastUsedAt = new Date().toISOString();
      return entry;
    }
  }
  return null;
}

Ordering is deliberate: normalize, enforce the format, then compare in constant time. Each step constrains the input the next step receives.

Position in the security stack

This layer secures the boundary between the local server and external services: the GitHub token used for portfolio sync and collection submission, and the local console that exposes the Gatekeeper and logs. Rate limiting and the optional TOTP second factor operate on the same boundary.

flowchart LR
  USER[Local console request] --> AUTH[authMiddleware: no-op unless DOLLHOUSE_WEB_AUTH_ENABLED]
  AUTH --> CT[consoleToken: constant-time verify, owner-only file]
  CT -- "authenticated" --> CONSOLE[Console: permissions, logs, metrics]
  SYNC[Portfolio sync / collection submit] --> TM[tokenManager: decrypt token in memory]
  TM --> RL[Rate limiter + safe downloader]
  RL --> GH[GitHub API]

Related