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:
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user