@gotcos/glasses-server 6.12.3 → 6.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 6.12.5
4
+
5
+ Extends the file-permission hardening to the remaining append-only logs that
6
+ the launch review named.
7
+
8
+ - **Run ledgers and the token-audit log are private at the file level.** The
9
+ Claude and Codex run ledgers and the token-audit JSONL now create with mode
10
+ 0600 and repair existing files with chmod, matching the session-log and
11
+ atomic-fs writers. They already sat under the 0700 data directory; this
12
+ closes the file-level bit for defense in depth and covers the token-audit log
13
+ specifically, which lives beside the data directory rather than inside it.
14
+
15
+ ## 6.12.4
16
+
17
+ Completes the public launch security review with a constant-time token check.
18
+
19
+ - **API tokens compare in constant time.** The `/api` middleware and the
20
+ OpenAI-compatible `/v1/chat/completions` Bearer check no longer use a plain
21
+ `!==` string compare, which short-circuits on the first differing byte and
22
+ leaks token bytes through response timing. Both now hash each side to a fixed
23
+ SHA-256 digest and compare with `crypto.timingSafeEqual`. Missing headers,
24
+ duplicated headers, and length-mismatched tokens fail closed without throwing.
25
+ A new `token-auth` test pins the behavior. 401 responses are otherwise
26
+ unchanged.
27
+
3
28
  ## 6.12.3
4
29
 
5
30
  Security hardening from the public launch review, without changing app/server
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotcos/glasses-server",
3
- "version": "6.12.3",
3
+ "version": "6.12.5",
4
4
  "description": "COS Glasses — self-hosted AI heads-up-display server for Even G2 smart glasses, powered by your local Claude Code or Codex CLI",
5
5
  "type": "module",
6
6
  "bin": {
package/server/index.ts CHANGED
@@ -57,6 +57,7 @@ import {
57
57
  isAllowedNetworkOrigin,
58
58
  isTailscaleIpv4,
59
59
  } from './lib/network-policy.js'
60
+ import { timingSafeTokenEqual } from './lib/token-auth.js'
60
61
 
61
62
  const app = express()
62
63
  const PORT = parseInt(process.env.PORT ?? '3141', 10)
@@ -128,7 +129,7 @@ app.use('/api', (req, res, next) => {
128
129
  req.path === '/diag/client' ||
129
130
  req.path === '/diag/health'
130
131
  ) return next()
131
- if (req.headers['x-cos-token'] !== API_TOKEN) {
132
+ if (!timingSafeTokenEqual(req.headers['x-cos-token'], API_TOKEN)) {
132
133
  return res.status(401).json({ error: 'unauthorized' })
133
134
  }
134
135
  next()
@@ -1,5 +1,5 @@
1
1
  import crypto from 'node:crypto'
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
5
5
  import { dataPath } from './data-dir.js'
@@ -118,7 +118,8 @@ function appendEvent(event: ClaudeRunEvent): void {
118
118
  try {
119
119
  const path = getClaudeLedgerPath()
120
120
  mkdirSync(dirname(path), { recursive: true })
121
- appendFileSync(path, JSON.stringify(event) + '\n')
121
+ appendFileSync(path, JSON.stringify(event) + '\n', { encoding: 'utf8', mode: 0o600 })
122
+ chmodSync(path, 0o600)
122
123
  } catch (err) {
123
124
  console.warn('[claude-run-ledger] write skipped:', err)
124
125
  }
@@ -1,5 +1,5 @@
1
1
  import crypto from 'node:crypto'
2
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
3
3
  import { dirname, resolve } from 'node:path'
4
4
  import { COS_SCRIPTS_DIR } from './python-bridge.js'
5
5
  import { cosBrainDir } from './launch-dir.js'
@@ -140,7 +140,8 @@ function appendEvent(event: CodexRunEvent): void {
140
140
  try {
141
141
  const path = getCodexLedgerPath()
142
142
  mkdirSync(dirname(path), { recursive: true })
143
- appendFileSync(path, JSON.stringify(event) + '\n')
143
+ appendFileSync(path, JSON.stringify(event) + '\n', { encoding: 'utf8', mode: 0o600 })
144
+ chmodSync(path, 0o600)
144
145
  } catch (err) {
145
146
  console.warn('[codex-run-ledger] write skipped:', err)
146
147
  }
@@ -2,7 +2,7 @@
2
2
  // Both Python (COS scripts) and TypeScript (G2 glasses) write to the same JSONL.
3
3
  // No LLM calls. Pure file append.
4
4
 
5
- import { appendFileSync } from 'node:fs'
5
+ import { appendFileSync, chmodSync } from 'node:fs'
6
6
  import { resolve } from 'node:path'
7
7
  import { homedir } from 'node:os'
8
8
 
@@ -43,7 +43,8 @@ export function logTokenAudit(entry: TokenAuditEntry): void {
43
43
  caller: entry.caller,
44
44
  }
45
45
  try {
46
- appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n')
46
+ appendFileSync(AUDIT_FILE, JSON.stringify(record) + '\n', { encoding: 'utf8', mode: 0o600 })
47
+ chmodSync(AUDIT_FILE, 0o600)
47
48
  } catch {
48
49
  // Never let logging break the actual call
49
50
  }
@@ -0,0 +1,26 @@
1
+ // Constant-time API-token comparison for the public server.
2
+
3
+ import { createHash, timingSafeEqual } from 'node:crypto'
4
+
5
+ /**
6
+ * Compare a client-supplied API token against the expected token without
7
+ * leaking token bytes through response timing.
8
+ *
9
+ * A plain `provided !== expected` string compare short-circuits at the first
10
+ * differing byte, so an attacker measuring response latency can recover the
11
+ * token one byte at a time. Hashing both sides to a fixed 32-byte SHA-256
12
+ * digest before `timingSafeEqual` keeps the comparison constant time
13
+ * regardless of input length, and avoids `timingSafeEqual` throwing on
14
+ * length-mismatched buffers.
15
+ *
16
+ * Non-string input (missing header, duplicated header parsed as an array) and
17
+ * an empty expected token never match.
18
+ */
19
+ export function timingSafeTokenEqual(provided: unknown, expected: string): boolean {
20
+ if (typeof provided !== 'string' || typeof expected !== 'string' || expected.length === 0) {
21
+ return false
22
+ }
23
+ const a = createHash('sha256').update(provided).digest()
24
+ const b = createHash('sha256').update(expected).digest()
25
+ return timingSafeEqual(a, b)
26
+ }
@@ -13,6 +13,7 @@ import {
13
13
  } from '../lib/codex-model-catalog.js'
14
14
  import { tryInstantResponse } from '../lib/response-cache.js'
15
15
  import crypto from 'node:crypto'
16
+ import { timingSafeTokenEqual } from '../lib/token-auth.js'
16
17
 
17
18
  export const openaiCompatRouter = Router()
18
19
 
@@ -66,7 +67,7 @@ function validateAuth(req: any, res: any): boolean {
66
67
  }
67
68
 
68
69
  const token = auth.slice(7)
69
- if (token !== cosToken) {
70
+ if (!timingSafeTokenEqual(token, cosToken)) {
70
71
  res.status(401).json({ error: { message: 'Invalid token', type: 'invalid_request_error' } })
71
72
  return false
72
73
  }