diff --git a/.changeset/harness-admin-step-up.md b/.changeset/harness-admin-step-up.md new file mode 100644 index 0000000..683eace --- /dev/null +++ b/.changeset/harness-admin-step-up.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": patch +--- + +`seamless verify` steps up the owner's admin session with a software passkey before admin writes, which the auth server requires from v0.20 (fells-code/seamless-auth-api#490). diff --git a/.changeset/types-0-30-0.md b/.changeset/types-0-30-0.md new file mode 100644 index 0000000..01105aa --- /dev/null +++ b/.changeset/types-0-30-0.md @@ -0,0 +1,5 @@ +--- +"seamless-cli": patch +--- + +Update `@seamless-auth/types` to 0.30.0. Its new `recovery_codes` and `account_recovery` config keys are not sent by `config set` or `config apply` until auth server v0.20 accepts them (#310). diff --git a/package-lock.json b/package-lock.json index fcd24f2..c098195 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "seamless-cli", - "version": "0.24.0", + "version": "0.25.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "seamless-cli", - "version": "0.24.0", + "version": "0.25.1", "license": "Apache-2.0", "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.30.0", "adm-zip": "^0.6.1", "kleur": "^4.1.5", "zod": "^4.6.5" @@ -1541,9 +1541,9 @@ "peer": true }, "node_modules/@seamless-auth/types": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.29.0.tgz", - "integrity": "sha512-Moiuw17RNZaORrrb5Wypjvl1iBj5K1PqDQkkK8ACYh9NSHD7khMyHaTsx9AzK6zOqROXgRRrIAurk9S1w2CZhA==", + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.30.0.tgz", + "integrity": "sha512-1MM/jBSG901HS35pLCNhEcOjA1KYLcBIxQZHYyexOOQ/7R8VNjxlmxG0L26VPV+FhA7Rkq5sBIifCLbR+g+0zA==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index 4df52b1..b13dfff 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "dependencies": { "@clack/prompts": "^1.0.1", "@napi-rs/keyring": "^1.3.0", - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.30.0", "adm-zip": "^0.6.1", "kleur": "^4.1.5", "zod": "^4.6.5" diff --git a/src/core/systemConfig.ts b/src/core/systemConfig.ts index fce31d6..f350648 100644 --- a/src/core/systemConfig.ts +++ b/src/core/systemConfig.ts @@ -56,7 +56,10 @@ export const WRITABLE_KEYS = [ // In the shared patch schema but not yet accepted by a released auth API this CLI // can rely on, each with the ticket that tracks it. Move a key to WRITABLE_KEYS // once it is. -export const NOT_YET_WRITABLE = {} as const satisfies Partial< +export const NOT_YET_WRITABLE = { + recovery_codes: "fells-code/seamless-cli#310", + account_recovery: "fells-code/seamless-cli#310", +} as const satisfies Partial< Record >; diff --git a/verify/harness/lib/cutoverFlows.ts b/verify/harness/lib/cutoverFlows.ts index 117fc39..5578ab4 100644 --- a/verify/harness/lib/cutoverFlows.ts +++ b/verify/harness/lib/cutoverFlows.ts @@ -6,13 +6,12 @@ import { MOCK_OIDC_ISSUER } from '../mock-oidc'; import { MOCK_OIDC_PORT, OWNER_EMAIL } from './env'; import { registerEmail, requestEmailOtp, verifyEmailOtp } from './flows'; import { SoftwareAuthenticator } from './softwareAuthenticator'; +import { registerPasskey, stepUpOwner } from './stepUp'; // Helpers for the migration cutover path (fells-code/seamless-auth-api#337): a legacy // OIDC provider that links imported users by their directory id, prompts them into // passkey enrollment, and can be retired per organization. -// The browser origin the stack accepts for WebAuthn (ORIGINS / RPID in the compose file). -const WEBAUTHN_ORIGIN = 'http://localhost:5173'; const REDIRECT_URI = 'http://localhost:5173/oauth/callback'; export function bearer(token: string) { @@ -20,7 +19,7 @@ export function bearer(token: string) { } /** - * An admin access token. Only OWNER_EMAIL is granted admin at signup, so this signs + * An admin access token, stepped up for admin writes. Only OWNER_EMAIL is granted admin at signup, so this signs * in as the owner; registering an address that already has an account continues * that account, so it works whether or not the owner spec has run first. */ @@ -29,7 +28,9 @@ export async function ownerAdminToken(ctx: APIRequestContext): Promise { const code = await requestEmailOtp(ctx, ephemeral); const res = await verifyEmailOtp(ctx, ephemeral, code); expect(res.ok(), `owner sign-in -> ${res.status()} ${await res.text()}`).toBeTruthy(); - return (await res.json()).token as string; + const { token } = (await res.json()) as { token: string }; + await stepUpOwner(ctx, token); + return token; } /** Adds an OIDC provider that verifies the mock's ID tokens and links on `oid`. */ @@ -108,27 +109,5 @@ export async function legacySignIn( /** Enrolls a passkey on the account the access token belongs to. */ export async function enrollPasskey(ctx: APIRequestContext, accessToken: string): Promise { - const start = await ctx.get('/webAuthn/register/start', { headers: bearer(accessToken) }); - expect(start.ok(), `register start -> ${start.status()} ${await start.text()}`).toBeTruthy(); - const options = await start.json(); - - const attestationResponse = new SoftwareAuthenticator().register({ - challenge: options.challenge, - origin: WEBAUTHN_ORIGIN, - rpId: options.rp.id, - }); - - const finish = await ctx.post('/webAuthn/register/finish', { - headers: bearer(accessToken), - data: { - attestationResponse, - metadata: { - friendlyName: 'verify harness', - platform: 'node', - browser: 'none', - deviceInfo: 'software authenticator', - }, - }, - }); - expect(finish.ok(), `register finish -> ${finish.status()} ${await finish.text()}`).toBeTruthy(); + await registerPasskey(ctx, accessToken, new SoftwareAuthenticator()); } diff --git a/verify/harness/lib/flows.ts b/verify/harness/lib/flows.ts index 78d9e92..8ec9e6f 100644 --- a/verify/harness/lib/flows.ts +++ b/verify/harness/lib/flows.ts @@ -1,6 +1,7 @@ import { APIRequestContext, expect } from '@playwright/test'; import { EXTERNAL_DELIVERY } from './env'; +import { stepUpOwner } from './stepUp'; export interface SessionTokens { token: string; // signed access token @@ -169,9 +170,9 @@ export async function oauthLogin( } /** - * An access token for the tenant owner (OWNER_EMAIL, admin at signup), for the - * admin API. Signs the owner in when the account exists (the api layer usually - * registered it earlier in the run) and registers it otherwise. An unknown + * An access token for the tenant owner (OWNER_EMAIL, admin at signup), stepped up + * for the admin API. Signs the owner in when the account exists (the api layer + * usually registered it earlier in the run) and registers it otherwise. An unknown * identifier gets a decoy OTP send that delivers nothing, which is how the two * cases are told apart. */ @@ -189,10 +190,14 @@ export async function ownerAccessToken(ctx: APIRequestContext, ownerEmail: strin data: { verificationToken: String(code) }, }); expect(res.ok(), `verify-login-email-otp -> ${res.status()} ${await res.text()}`).toBeTruthy(); - return (await res.json()).token as string; + const { token } = (await res.json()) as { token: string }; + await stepUpOwner(ctx, token); + return token; } - return (await registerAndVerifyEmail(ctx, ownerEmail)).token; + const { token } = await registerAndVerifyEmail(ctx, ownerEmail); + await stepUpOwner(ctx, token); + return token; } /** Replace a user's roles through the admin API. */ diff --git a/verify/harness/lib/softwareAuthenticator.ts b/verify/harness/lib/softwareAuthenticator.ts index 774f584..733da95 100644 --- a/verify/harness/lib/softwareAuthenticator.ts +++ b/verify/harness/lib/softwareAuthenticator.ts @@ -1,4 +1,13 @@ -import { createHash, generateKeyPairSync, KeyObject, randomBytes } from 'crypto'; +import { + createECDH, + createHash, + createPrivateKey, + createPublicKey, + generateKeyPairSync, + KeyObject, + randomBytes, + sign, +} from 'crypto'; // A minimal ES256 authenticator with `fmt: 'none'` attestation, enough to enroll a // passkey through the API without a browser. The browser layer covers the real @@ -37,16 +46,49 @@ function clientData(type: string, challenge: string, origin: string): Buffer { } export class SoftwareAuthenticator { - private readonly credentialId = randomBytes(32); + private readonly credentialId: Buffer; + private readonly privateKey: KeyObject; private readonly publicKey: KeyObject; private counter = 0; - constructor() { - this.publicKey = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }).publicKey; + /** + * With a seed, the key and credential id are derived from it, so a later process + * (a Playwright worker restarted after a failure) can still sign for a passkey an + * earlier one enrolled. + */ + constructor(seed?: string) { + if (!seed) { + this.credentialId = randomBytes(32); + ({ privateKey: this.privateKey, publicKey: this.publicKey } = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + })); + return; + } + + this.credentialId = createHash('sha256').update(`credential:${seed}`).digest(); + const d = createHash('sha256').update(`key:${seed}`).digest(); + const ecdh = createECDH('prime256v1'); + ecdh.setPrivateKey(d); + const point = ecdh.getPublicKey(); + this.privateKey = createPrivateKey({ + key: { + kty: 'EC', + crv: 'P-256', + d: d.toString('base64url'), + x: point.subarray(1, 33).toString('base64url'), + y: point.subarray(33).toString('base64url'), + }, + format: 'jwk', + }); + this.publicKey = createPublicKey(this.privateKey); + } + + get id() { + return this.credentialId.toString('base64url'); } register(params: { challenge: string; origin: string; rpId: string }) { - const id = this.credentialId.toString('base64url'); + const id = this.id; const attestationObject = cbor( new Map([ ['fmt', 'none'], @@ -70,9 +112,44 @@ export class SoftwareAuthenticator { }; } - private authData(rpId: string): Buffer { + /** An assertion for `navigator.credentials.get`, signed with this credential's key. */ + authenticate(params: { challenge: string; origin: string; rpId: string }) { + const authenticatorData = Buffer.concat([ + createHash('sha256').update(params.rpId).digest(), + Buffer.from([0x05]), // UP | UV + this.signCount(), + ]); + const clientDataJSON = clientData('webauthn.get', params.challenge, params.origin); + const signature = sign( + 'sha256', + Buffer.concat([authenticatorData, createHash('sha256').update(clientDataJSON).digest()]), + this.privateKey, + ); + + return { + id: this.id, + rawId: this.id, + type: 'public-key', + clientExtensionResults: {}, + response: { + clientDataJSON: clientDataJSON.toString('base64url'), + authenticatorData: authenticatorData.toString('base64url'), + signature: signature.toString('base64url'), + }, + }; + } + + // Seconds since the epoch, so it keeps increasing across processes sharing a seed, + // and at least one past the last use, since the API refuses a count that does not grow. + private signCount(): Buffer { + this.counter = Math.max(Math.floor(Date.now() / 1000), this.counter + 1); const counter = Buffer.alloc(4); - counter.writeUInt32BE(++this.counter); + counter.writeUInt32BE(this.counter); + return counter; + } + + private authData(rpId: string): Buffer { + const counter = this.signCount(); const idLength = Buffer.alloc(2); idLength.writeUInt16BE(this.credentialId.length); diff --git a/verify/harness/lib/stepUp.ts b/verify/harness/lib/stepUp.ts new file mode 100644 index 0000000..1fc3a14 --- /dev/null +++ b/verify/harness/lib/stepUp.ts @@ -0,0 +1,82 @@ +import { APIRequestContext, expect } from '@playwright/test'; + +import { OWNER_EMAIL } from './env'; +import { SoftwareAuthenticator } from './softwareAuthenticator'; + +// The browser origin the stack accepts for WebAuthn (ORIGINS / RPID in the compose file). +export const WEBAUTHN_ORIGIN = 'http://localhost:5173'; + +// One instance per worker, so its sign count keeps growing between owner sign-ins. +const ownerAuthenticator = new SoftwareAuthenticator(`verify-owner:${OWNER_EMAIL}`); + +function bearer(token: string) { + return { Authorization: `Bearer ${token}` }; +} + +/** Enrolls `authenticator` as a passkey on the signed-in account. */ +export async function registerPasskey( + ctx: APIRequestContext, + accessToken: string, + authenticator: SoftwareAuthenticator, +): Promise { + const start = await ctx.get('/webAuthn/register/start', { headers: bearer(accessToken) }); + expect(start.ok(), `register start -> ${start.status()} ${await start.text()}`).toBeTruthy(); + const options = await start.json(); + + const finish = await ctx.post('/webAuthn/register/finish', { + headers: bearer(accessToken), + data: { + attestationResponse: authenticator.register({ + challenge: options.challenge, + origin: WEBAUTHN_ORIGIN, + rpId: options.rp.id, + }), + metadata: { + friendlyName: 'verify harness', + platform: 'node', + browser: 'none', + deviceInfo: 'software authenticator', + }, + }, + }); + expect(finish.ok(), `register finish -> ${finish.status()} ${await finish.text()}`).toBeTruthy(); +} + +/** + * Freshens the owner session's step-up, which admin writes require. Uses one passkey + * derived from OWNER_EMAIL, enrolled the first time it is needed. + */ +export async function stepUpOwner(ctx: APIRequestContext, accessToken: string): Promise { + const begin = async () => { + const res = await ctx.post('/step-up/webauthn/start', { + headers: bearer(accessToken), + data: {}, + }); + if (res.status() === 401) return null; + expect(res.ok(), `step-up start -> ${res.status()} ${await res.text()}`).toBeTruthy(); + const options = await res.json(); + const enrolled = (options.allowCredentials ?? []).some( + (credential: { id: string }) => credential.id === ownerAuthenticator.id, + ); + return enrolled ? options : null; + }; + + let options = await begin(); + if (!options) { + await registerPasskey(ctx, accessToken, ownerAuthenticator); + options = await begin(); + } + expect(options, 'step-up offers the owner passkey').toBeTruthy(); + + const finish = await ctx.post('/step-up/webauthn/finish', { + headers: bearer(accessToken), + data: { + assertionResponse: ownerAuthenticator.authenticate({ + challenge: options.challenge, + origin: WEBAUTHN_ORIGIN, + rpId: options.rpId, + }), + }, + }); + expect(finish.ok(), `step-up finish -> ${finish.status()} ${await finish.text()}`).toBeTruthy(); +}