feat(control-plane): POST /auth/login mints device:enroll bearer (B1)

Unblocks the phone-enrollment track: an operator-password login mints a
short-lived device:enroll capability token that POST /device/enroll requires.
Constant-time (SHA-256 fixed-length) compare, per-client rate-limit, fail-closed
when unset. 260 tests pass.
This commit is contained in:
Yaojia Wang
2026-07-18 13:32:05 +02:00
parent 232ef22535
commit fff011bb7f
7 changed files with 482 additions and 3 deletions

View File

@@ -22,7 +22,7 @@
import type { CapabilityRight, CapabilityToken } from 'relay-contracts'
import { verifyCapabilityToken } from 'relay-auth'
import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
import { randomBytes, randomUUID } from 'node:crypto'
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { timingSafeEqualBytes } from '../util/bytes.js'
/** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */
@@ -157,8 +157,11 @@ export function loginToAccountId(credential: string, config: LoginSeamConfig): s
return acct
}
if (config.operatorCredential !== undefined && config.accountId !== undefined) {
const a = new TextEncoder().encode(credential)
const b = new TextEncoder().encode(config.operatorCredential)
// SHA-256 both to a fixed 32 bytes BEFORE comparing, so the compare is
// length-independent — timingSafeEqualBytes short-circuits on unequal length,
// which would otherwise leak the operator credential's length via timing.
const a = createHash('sha256').update(credential, 'utf8').digest()
const b = createHash('sha256').update(config.operatorCredential, 'utf8').digest()
if (timingSafeEqualBytes(a, b)) return config.accountId
throw new DeviceEnrollAuthError(401, 'login rejected')
}