diff --git a/apps/ios/ClientControlNative.tsx b/apps/ios/ClientControlNative.tsx index 389d2b7575a..cb2b36ec5dc 100644 --- a/apps/ios/ClientControlNative.tsx +++ b/apps/ios/ClientControlNative.tsx @@ -64,8 +64,8 @@ class CommandSubscription extends React.Component { private stop: (() => void) | undefined; private start = () => { const { binding, feed, granted, reportError } = this.props; - this.stop = feed.followCommands(true, granted, command => { - void binding.controller.handle(command).catch(error => reportError(String(error))); + this.stop = feed.followCommands(true, granted, (command, snapshot) => { + void binding.controller.handle(command, snapshot).catch(error => reportError(String(error))); }, binding.controller.suspend); }; componentDidMount() { this.start(); } @@ -98,9 +98,13 @@ export const ClientControlNative = () => { navigate: navigation.navigate, result: async (binding, current) => { if (!current()) return; - if (!globalThis.crypto?.subtle) throw new Error('Client-control signing needs the generated native SHA-256 port'); - const result = await signClientResult(binding, controlProfile.signing, signWithDeviceKey, Date.now(), base64url(Crypto.getRandomBytes(16))); - if (!current()) return; + const result = await signClientResult(binding, controlProfile.signing, signWithDeviceKey, Date.now(), base64url(Crypto.getRandomBytes(16)), + async bytes => { + const input = bytes.buffer instanceof ArrayBuffer + ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) : Uint8Array.from(bytes); + return new Uint8Array(await Crypto.digest(Crypto.CryptoDigestAlgorithm.SHA256, input)); + }); + if (!result || !current()) return; await client.deviceResult(result); }, revoke: () => revokeClientControl(client, controlProfile.identity.device), diff --git a/apps/ios/README.md b/apps/ios/README.md index ab2e092318a..0fe10eaade1 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -49,7 +49,9 @@ The app holds one WebSocket to the gateway, the collections socket (`docs/st3/cl The fifteen-second collections opening deadline includes the WebSocket handshake, not just creation of its carrier. The receive-heartbeat deadline starts after the handshake; only a base-window snapshot marks the feed live and resets retry backoff. Opening attempts close their pending carrier when timed out, superseded, or backgrounded. Native socket failures retain only the NSError domain and numeric code, never its localized message. Transient iOS `inactive` states such as the Share sheet preserve the previous foreground state: a live feed keeps receiving and retrying, while a backgrounded feed waits for `active` before resuming. -Commands use this same session-scoped socket only when Expo extra `testClientMode` is exactly `true`, `phone-commands` v1 is granted, and the saved test profile binds the current credential, device key and session. Missing test mode means the normal app: no commands subscription. Each command requires its generated `control_grant` with matching device/session/grant/run/issuer, verb/screen, optional item ID and active windows. Commands are single-use and bounded by the grant budget; disconnect/revoke/disposal cancels in-flight results. The generated typed idle grant lifecycle is still unavailable: the indicator stays hidden pending that contract, not inferred from a command. Native signing fails closed without WebCrypto until codegen accepts an Expo SHA-256 callback; no crypto polyfill is installed. +Commands use this same session-scoped socket only when Expo extra `testClientMode` is exactly `true`, `phone-commands` v1 is granted, and the saved test profile binds the current credential, device key and session. Missing test mode means the normal app: no commands subscription. Each command requires a matching started lifecycle grant and generated `control_grant` with matching device/session/grant/run/issuer, verb/screen, optional item ID and active windows. Commands are single-use and bounded by the grant budget; disconnect/revoke/disposal cancels in-flight results. Generated lifecycle events update the run indicator even while idle; terminal events clear authority. Sequences must be positive safe integers; stale/duplicate events cannot reopen authority. Signing passes Expo's native SHA-256 digest to generated canonicalization, without WebCrypto or a polyfill. Invalid Unicode is refused without posting a result. + +Suspension clears current authority, not lifecycle history or terminal tombstones. A fresh commands snapshot explicitly reconciles an unchanged current started lifecycle; incremental duplicates and lower-sequence snapshots remain ignored, and terminal grants never reopen. The local v1 policy permits only inspect and navigation to `NowRoot` (inbox) or `Attention` with `params: {id}`. Conversation and answer/read/close operations are excluded. The seeded request uses the normal request renderer, not a test-only view: plain single-paragraph text renders as one containing native text root; rich Markdown or JSON reports can render multiple elements. Source-level JSX tests cover the fixture body and request title; native accessibility-tree observation remains a separate XCUITest proof. diff --git a/apps/ios/clientControl.test.mjs b/apps/ios/clientControl.test.mjs index beeca42abb4..e700113f323 100644 --- a/apps/ios/clientControl.test.mjs +++ b/apps/ios/clientControl.test.mjs @@ -4,17 +4,31 @@ import { readFileSync } from 'node:fs'; import { ClientControlController } from './clientControl.ts'; const fixture = JSON.parse(readFileSync(new URL('../../fixtures/clients/client-control-v1.golden.json', import.meta.url))); const binding = fixture.vectors[1].input; +const lifecycleFixture = JSON.parse(readFileSync(new URL('../../fixtures/clients/client-control-lifecycle-v1.json', import.meta.url))); +const lifecycle = (changes = {}) => ({ ...lifecycleFixture.resources[0], device: binding.device, session: binding.session, grant_id: binding.grant_id, run_id: binding.run_id, item_id: undefined, ...changes }); const grant = () => ({ grant_id: binding.grant_id, run_id: binding.run_id, device: binding.device, session: binding.session, actor_agent: 'agent/fixture', state: 'active', verbs: ['phone.inspect', 'phone.navigate'], screens: ['NowRoot', 'Attention'], max_commands: 50, expires_at: new Date(Date.now() + 60_000).toISOString() }); const command = () => ({ kind: 'device-command', id: binding.command_id, state: 'delivered', command_kind: 'phone.navigate', args: { screen: 'Attention', params: { id: binding.visible_state.opened_item.id } }, device_id: binding.device, session_actor: binding.session, issued_by: 'agent/fixture', grant_id: binding.grant_id, run_id: binding.run_id, expires_at: new Date(Date.now() + 30_000).toISOString(), control_grant: grant() }); -const harness = (t, changes = {}) => { +const harness = (t, changes = {}, started = lifecycle()) => { const effects = [], results = []; const controller = new ClientControlController({ identity: { device: binding.device, session: binding.session }, foreground: () => true, visible: () => binding.visible_state, navigate: async args => { effects.push(args); }, result: async (value, current) => { assert.equal(current(), true); results.push(value); }, revoke: async () => { effects.push('revoke'); }, answer: () => assert.fail('answer callback'), close: () => assert.fail('close callback'), read: () => assert.fail('read callback'), ...changes }); t.after(controller.dispose); + if (started) void controller.handle(started); return { controller, effects, results }; }; +for (const scenario of ['unchanged', 'revoked', 'stale']) test(`snapshot reconciliation after reset: ${scenario}`, async t => { + const started = lifecycle({ sequence: 10 }), { controller, effects, results } = harness(t, {}, started); + if (scenario === 'revoked') await controller.handle({ ...started, state: 'revoked', sequence: 11 }); + controller.suspend(); + await controller.handle({ ...started, sequence: scenario === 'stale' ? 9 : 10 }, true); + assert.equal(controller.getGrant()?.run_id, scenario === 'unchanged' ? started.run_id : undefined); + await controller.handle(command()); + assert.equal(effects.length, scenario === 'unchanged' ? 1 : 0); + assert.equal(results.length, scenario === 'unchanged' ? 1 : 0); +}); + for (const [name, change] of [ ['no grant', row => { delete row.control_grant; }], ['wrong run', row => { row.run_id += '/wrong'; }], ['wrong device', row => { row.device_id += '/wrong'; }], ['wrong session', row => { row.session_actor += '/wrong'; }], ['wrong issuer', row => { row.issued_by += '/wrong'; }], ['non-granted screen', row => { row.control_grant.screens = ['NowRoot']; }], ['non-granted verb', row => { row.control_grant.verbs = ['phone.inspect']; }], @@ -28,16 +42,18 @@ for (const [name, change] of [ }); test('inspect and navigate use only pure ports and committed state; optional item_id and replay', async t => { - const { controller, effects, results } = harness(t), originalFetch = globalThis.fetch; + const started = lifecycle({ item_id: binding.visible_state.opened_item.id }); + const { controller, effects, results } = harness(t, {}, started), originalFetch = globalThis.fetch; globalThis.fetch = () => assert.fail('no answer/read/close API'); try { const row = command(); row.control_grant.item_id = row.args.params.id; await controller.handle(row); await controller.handle(row); const inspect = { ...command(), id: row.id + '/inspect', command_kind: 'phone.inspect', args: {} }; + inspect.control_grant.item_id = row.control_grant.item_id; await controller.handle(inspect); assert.deepEqual(effects, [row.args]); assert.equal(results.length, 2); assert.deepEqual(results[0].visible_state, binding.visible_state); - assert.equal(controller.getGrant(), undefined, 'idle indicator requires generated lifecycle authority'); + assert.equal(controller.getGrant(), started, 'only lifecycle publishes the run indicator'); } finally { globalThis.fetch = originalFetch; } }); test('background command reports not-foreground without navigation', async t => { @@ -58,4 +74,68 @@ test('disconnect invalidates in-flight result', async t => { const applying = controller.handle(command()); await entered.promise; controller.suspend(); pending.resolve(); await applying; assert.deepEqual(results, []); }); -test.todo('#6274: idle started/ended/revoked grant lifecycle resource is not in the generated collection union yet'); +test('commands never infer an active lifecycle', async t => { + const { controller, effects, results } = harness(t, {}, false); + await controller.handle(command()); + assert.equal(controller.getGrant(), undefined); assert.deepEqual(effects, []); assert.deepEqual(results, []); +}); + +test('idle lifecycle enforces own identity, ordering, item binding and terminal finality', async t => { + const { controller } = harness(t, {}, false); + const started = lifecycle({ item_id: 'attention/bound', sequence: 2 }); + for (const change of [{ device: 'device/other' }, { session: 'session/other' }]) { + await controller.handle({ ...started, ...change }); assert.equal(controller.getGrant(), undefined); + } + void controller.handle(started); assert.equal(controller.getGrant(), started); + for (const change of [{ sequence: 1 }, { sequence: 2 }, { item_id: undefined, sequence: 3 }, { item_id: 'other', sequence: 3 }, { run_id: 'other', sequence: 3 }]) { + await controller.handle({ ...started, ...change }); assert.equal(controller.getGrant(), started); + } + void controller.handle({ ...started, state: 'ended', reason: 'claim-ended', sequence: 3 }); + assert.equal(controller.getGrant(), undefined); + await controller.handle({ ...started, sequence: 4 }); assert.equal(controller.getGrant(), undefined); +}); + +test('golden sequence zero and unsafe sequences fail closed', async t => { + const invalid = lifecycleFixture.traces[0].steps.find(step => step.expected === 'refuse-invalid-frame').resource; + assert.equal(invalid.sequence, 0); + const golden = harness(t, { identity: { device: invalid.device, session: invalid.session } }, lifecycleFixture.resources[0]); + void golden.controller.handle(invalid); + assert.equal(golden.controller.getGrant(), undefined, 'shared invalid resource immediately clears active authority'); + for (const sequence of [invalid.sequence, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) { + const { controller, effects, results } = harness(t); + void controller.handle(lifecycle({ sequence })); assert.equal(controller.getGrant(), undefined); + await controller.handle(command()); assert.deepEqual(effects, []); assert.deepEqual(results, []); + } + const { controller } = harness(t); + await controller.handle(lifecycle({ sequence: Number.MAX_SAFE_INTEGER })); + assert.equal(controller.getGrant().sequence, Number.MAX_SAFE_INTEGER); +}); + +for (const [state, reason] of [['ended', 'run-ended'], ['ended', 'claim-ended'], ['revoked', 'revoked']]) test(`${reason} clears idle and in-flight authority synchronously`, async t => { + const idle = harness(t); + void idle.controller.handle(lifecycle({ state, reason, sequence: 2 })); + assert.equal(idle.controller.getGrant(), undefined); + const pending = Promise.withResolvers(), entered = Promise.withResolvers(); + const { controller, results } = harness(t, { navigate: async () => { entered.resolve(); await pending.promise; } }); + const applying = controller.handle(command()); await entered.promise; + const queued = controller.handle({ ...command(), id: 'queued' }); + void controller.handle(lifecycle({ state, reason, sequence: 2 })); + assert.equal(controller.getGrant(), undefined); + pending.resolve(); await applying; await queued; assert.deepEqual(results, []); +}); + +test('lifecycle must match every command grant field, including optional item', async t => { + for (const change of [{ grant_id: 'other' }, { run_id: 'other' }, { device: 'other' }, { session: 'other' }, { item_id: 'other' }]) { + const { controller, effects, results } = harness(t, {}, lifecycle(change)); + await controller.handle(command()); assert.deepEqual(effects, []); assert.deepEqual(results, []); + } +}); + +test('command budget is preserved across lifecycle updates and duplicate delivery', async t => { + const { controller, effects, results } = harness(t); + const row = command(); row.control_grant.max_commands = 1; + await controller.handle(row); await controller.handle(row); + await controller.handle(lifecycle({ sequence: 2 })); + await controller.handle({ ...row, id: 'over-budget' }); + assert.equal(effects.length, 1); assert.equal(results.length, 1); +}); diff --git a/apps/ios/clientControl.ts b/apps/ios/clientControl.ts index a7b971c2901..71b04095bf5 100644 --- a/apps/ios/clientControl.ts +++ b/apps/ios/clientControl.ts @@ -1,4 +1,4 @@ -import { CLIENT_RESULT_PURPOSE, type ClientControlGrant, type ClientResultBinding, type ClientVisibleState, type DeviceCommand, type PhoneNavigateArgs } from '../../clients/typescript/fractal-client'; +import { CLIENT_RESULT_PURPOSE, type ClientControlGrant, type ClientControlGrantLifecycle, type ClientResultBinding, type ClientVisibleState, type DeviceCommand, type PhoneNavigateArgs } from '../../clients/typescript/fractal-client'; export interface ClientControlPorts { identity: { device: string; session: string }; @@ -10,7 +10,8 @@ export interface ClientControlPorts { } const v1Screen = (screen: string): boolean => screen === 'NowRoot' || screen === 'Attention'; export class ClientControlController { - private grant: ClientControlGrant | undefined; + private grant: ClientControlGrantLifecycle | undefined; + private lifecycles = new Map(); private listeners = new Set<() => void>(); private expires: ReturnType | undefined; private pending = Promise.resolve(); @@ -19,8 +20,7 @@ export class ClientControlController { private epoch = 0; private seen = new Map>(); constructor(private readonly ports: ClientControlPorts) {} - // TODO #6274: only a generated lifecycle resource may publish the idle run indicator. - readonly getGrant = (): ClientControlGrant | undefined => undefined; + readonly getGrant = (): ClientControlGrantLifecycle | undefined => this.grant; readonly subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; }; readonly suspend = (): void => { this.epoch++; clearTimeout(this.expires); this.grant = undefined; @@ -28,9 +28,27 @@ export class ClientControlController { }; readonly dispose = (): void => { this.disposed = true; this.suspend(); this.listeners.clear(); }; readonly revoke = async (): Promise => { this.revoked = true; this.suspend(); await this.ports.revoke(); }; + private lifecycle(row: ClientControlGrantLifecycle, snapshot: boolean): void { + if (this.disposed || this.revoked || row.device !== this.ports.identity.device || row.session !== this.ports.identity.session) return; + if (!Number.isSafeInteger(row.sequence) || row.sequence <= 0 + || !['started', 'ended', 'revoked'].includes(row.state)) { this.suspend(); return; } + const previous = this.lifecycles.get(row.grant_id); + if (previous && (row.run_id !== previous.run_id || row.item_id !== previous.item_id + || row.sequence < previous.sequence || previous.state !== 'started' + || row.sequence === previous.sequence && (!snapshot || row.state !== 'started'))) return; + this.lifecycles.set(row.grant_id, row); + if (row.state === 'started') { + this.suspend(); this.grant = row; + for (const listener of this.listeners) listener(); + } else if (this.grant?.grant_id === row.grant_id) this.suspend(); + } private authorized(command: DeviceCommand, grant: ClientControlGrant): boolean { const now = Date.now(); - return !this.disposed && !this.revoked && grant.state === 'active' + const lifecycle = this.grant; + return !!lifecycle && lifecycle.state === 'started' && lifecycle.grant_id === grant.grant_id + && lifecycle.device === grant.device && lifecycle.session === grant.session + && lifecycle.run_id === grant.run_id && lifecycle.item_id === grant.item_id + && !this.disposed && !this.revoked && grant.state === 'active' && command.device_id === this.ports.identity.device && grant.device === this.ports.identity.device && command.session_actor === this.ports.identity.session && grant.session === this.ports.identity.session && command.grant_id === grant.grant_id && command.run_id === grant.run_id && command.issued_by === grant.actor_agent @@ -39,7 +57,8 @@ export class ClientControlController { && (command.command_kind !== 'phone.navigate' || v1Screen(command.args.screen) && grant.screens.includes(command.args.screen) && (!grant.item_id || command.args.screen !== 'Attention' || command.args.params.id === grant.item_id)); } - readonly handle = (command: DeviceCommand): Promise => { + readonly handle = (command: DeviceCommand | ClientControlGrantLifecycle, snapshot = false): Promise => { + if (command.kind === 'client-control-grant-lifecycle') { this.lifecycle(command, snapshot); return Promise.resolve(); } const epoch = this.epoch; const task = this.pending.then(() => epoch === this.epoch ? this.apply(command) : undefined); this.pending = task.catch(() => {}); @@ -52,11 +71,10 @@ export class ClientControlController { const seen = this.seen.get(grant.grant_id) ?? new Set(); if (seen.has(command.id) || seen.size >= grant.max_commands) return; seen.add(command.id); this.seen.set(grant.grant_id, seen); - this.grant = grant; clearTimeout(this.expires); + clearTimeout(this.expires); this.expires = setTimeout(this.suspend, Math.min(Date.parse(grant.expires_at) - Date.now(), 2_147_483_647)); - for (const listener of this.listeners) listener(); const epoch = this.epoch; - const current = () => epoch === this.epoch && this.grant === grant && this.authorized(command, grant); + const current = () => epoch === this.epoch && this.authorized(command, grant); const foreground = this.ports.foreground(); if (foreground && command.command_kind === 'phone.navigate') await this.ports.navigate(command.args); if (!current()) return; diff --git a/apps/ios/clientControlFeed.test.mjs b/apps/ios/clientControlFeed.test.mjs index c20be90b9f6..8f72154ba02 100644 --- a/apps/ios/clientControlFeed.test.mjs +++ b/apps/ios/clientControlFeed.test.mjs @@ -1,8 +1,11 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; +import { readFileSync } from 'node:fs'; import { ForegroundGate } from './foreground.ts'; import { fakeClient } from './fakeFeed.mjs'; +import { ClientControlController } from './clientControl.ts'; +const lifecycleFixture = JSON.parse(readFileSync(new URL('../../fixtures/clients/client-control-lifecycle-v1.json', import.meta.url))); const settle = () => new Promise(resolve => setTimeout(resolve, 20)); for (const [testMode, granted] of [[undefined, true], [false, false], [false, true], [true, false], [true, true]]) { test(`commands subscription test mode=${testMode} capability=${granted}`, async () => { @@ -16,9 +19,14 @@ for (const [testMode, granted] of [[undefined, true], [false, false], [false, tr const subscribed = () => sockets.at(-1).sent.some(frame => frame.collection === 'commands'); assert.equal(subscribed(), testMode === true && granted); const command = { kind: 'device-command', id: 'device-command/test', command_kind: 'phone.inspect', args: {}, state: 'delivered' }; - const frame = { kind: 'snapshot', id: 'commands', collection: 'commands', items: [command] }; + const lifecycle = lifecycleFixture.resources[0]; + const frame = { kind: 'snapshot', id: 'commands', collection: 'commands', items: [command, lifecycle] }; sockets[0].frame(frame); - assert.equal(received.length, testMode === true && granted ? 1 : 0); + assert.deepEqual(received, testMode === true && granted ? [lifecycle, command] : []); + for (const resource of lifecycleFixture.resources.slice(1)) { + sockets[0].frame({ ...frame, kind: 'changes', items: undefined, upserts: [resource], removes: [] }); + } + assert.deepEqual(received, testMode === true && granted ? [lifecycle, command, ...lifecycleFixture.resources.slice(1)] : []); foreground.update('background'); assert.equal(sockets[0].closed, true); assert.equal(resets > 0, testMode === true && granted, 'background invalidates grant authority'); @@ -32,3 +40,33 @@ for (const [testMode, granted] of [[undefined, true], [false, false], [false, tr feed.close(); }); } + +test('commands feed carries golden invalid sequence to the active controller', async t => { + const { Feed } = await import('./feed.ts'); + const { client, sockets } = fakeClient(); + const started = lifecycleFixture.resources[0]; + const invalid = lifecycleFixture.traces[0].steps.find(step => step.expected === 'refuse-invalid-frame').resource; + const controller = new ClientControlController({ identity: { device: started.device, session: started.session }, + foreground: () => true, visible: () => undefined, + navigate: async () => assert.fail('no navigation'), result: async () => assert.fail('no result'), revoke: async () => {} }); + const foreground = new ForegroundGate('active'); + const feed = new Feed(client, { onWindow() {}, onConnection() {} }, foreground, () => 'action/test'); + t.after(() => { feed.close(); controller.dispose(); }); + feed.followCommands(true, true, controller.handle, controller.suspend); + await settle(); + sockets[0].frame(lifecycleFixture.frames[0]); + assert.deepEqual(controller.getGrant(), started); + foreground.update('background'); assert.equal(controller.getGrant(), undefined); + foreground.update('active'); await settle(); + sockets.at(-1).frame(lifecycleFixture.frames[0]); + assert.deepEqual(controller.getGrant(), started, 'unchanged started snapshot restores the run indicator'); + sockets.at(-1).frame({ ...lifecycleFixture.frames[1], upserts: [invalid] }); + assert.equal(controller.getGrant(), undefined, 'sequence zero fails closed through the shipped feed path'); + for (const identity of ['device', 'session', 'absent']) await t.test(`replacement snapshot refuses ${identity} lifecycle`, () => { + sockets.at(-1).frame(lifecycleFixture.frames[0]); + assert.deepEqual(controller.getGrant(), started); + const items = identity === 'absent' ? [] : [{ ...started, [identity]: 'foreign' }]; + sockets.at(-1).frame({ ...lifecycleFixture.frames[0], items }); + assert.equal(controller.getGrant(), undefined, 'foreign or absent lifecycle never restores authority'); + }); +}); diff --git a/apps/ios/clientControlNative.test.mjs b/apps/ios/clientControlNative.test.mjs new file mode 100644 index 00000000000..dc9e0bf2ade --- /dev/null +++ b/apps/ios/clientControlNative.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { readFileSync } from 'node:fs'; +import ts from 'typescript'; +const source = ts.createSourceFile('ClientControlNative.tsx', readFileSync(new URL('./ClientControlNative.tsx', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); +const find = node => ts.isPropertyAssignment(node) && node.name.getText(source) === 'result' ? node.initializer : ts.forEachChild(node, find); +const expression = ts.transpileModule(`const result = ${find(source).getText(source)};`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText; +for (const refused of [false, true]) test(`shipped native result uses Expo digest without WebCrypto; refused=${refused}`, async () => { + const calls = [], bytes = new Uint8Array([1, 2, 3]); + const Crypto = { CryptoDigestAlgorithm: { SHA256: 'SHA256' }, getRandomBytes: size => new Uint8Array(size), digest: async (algorithm, input) => { calls.push(['digest', algorithm, input]); return bytes.buffer; } }; + const envelope = { command_id: 'command' }; + const signClientResult = async (_binding, _key, _sign, _time, _nonce, sha256) => { assert.deepEqual(await sha256(bytes), bytes); return refused ? undefined : envelope; }; + const result = new Function('Crypto', 'signClientResult', 'controlProfile', 'signWithDeviceKey', 'base64url', 'client', `${expression}; return result;`)(Crypto, signClientResult, { signing: {} }, () => {}, () => 'nonce', { deviceResult: async value => calls.push(['post', value]) }); + await result({}, () => true); + assert.deepEqual(calls, refused ? [['digest', 'SHA256', bytes]] : [['digest', 'SHA256', bytes], ['post', envelope]]); + calls.length = 0; await result({}, () => false); assert.deepEqual(calls, []); +}); diff --git a/apps/ios/clientControlWire.test.mjs b/apps/ios/clientControlWire.test.mjs index f09afc8f9cc..eb268600cc8 100644 --- a/apps/ios/clientControlWire.test.mjs +++ b/apps/ios/clientControlWire.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; +import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { test } from 'node:test'; import { clientResultCanonicalBytes, CLIENT_RESULT_SIGNED_FIELDS, visibleStateCanonicalJson, visibleStateDigest } from '../../clients/typescript/fractal-client/ClientControl.generated.ts'; @@ -11,6 +11,10 @@ const publicKey = createPublicKey({ key: Buffer.concat([ Buffer.from('3059301306072a8648ce3d020106082a8648ce3d030107034200', 'hex'), sec1, ]), format: 'der', type: 'spki' }); const verifies = (bytes, signature) => verify('sha256', Buffer.from(bytes), { key: publicKey, dsaEncoding: 'ieee-p1363' }, Buffer.from(signature, 'base64url')); +const privateKey = createPrivateKey({ key: { kty: 'EC', crv: 'P-256', + x: sec1.subarray(1, 33).toString('base64url'), y: sec1.subarray(33).toString('base64url'), + d: Buffer.from(golden.private_scalar_hex_for_tests, 'hex').toString('base64url'), +}, format: 'jwk' }); for (const vector of golden.vectors) test(`generated ${vector.name} JCS, digest, canonical bytes and P-256 binding`, async () => { assert.equal(visibleStateCanonicalJson(vector.input.visible_state), vector.canonical_visible_state); @@ -29,16 +33,12 @@ test('message-domain fixture signature is valid only in its own domain', () => { assert.equal(verifies(golden.message_domain_bytes, golden.message_domain_signature_p1363_base64url), true); }); -test('adapter signs generated bytes with the selected key and returns the generated envelope', async () => { - const binding = golden.vectors[0].input; +for (const vector of golden.vectors) test(`adapter signs ${vector.name} golden bytes with the selected key and generated envelope`, async () => { + const binding = vector.input; const signing = { key: `p256:${golden.public_key_sec1_base64url}`, person: 'person/test', chain: ['grant/test'], storage: 'secure-enclave', handle: 'selected-key' }; - const privateKey = createPrivateKey({ key: { kty: 'EC', crv: 'P-256', - x: sec1.subarray(1, 33).toString('base64url'), y: sec1.subarray(33).toString('base64url'), - d: Buffer.from(golden.private_scalar_hex_for_tests, 'hex').toString('base64url'), - }, format: 'jwk' }); const result = await signClientResult(binding, signing, async (text, handle) => { assert.equal(handle, signing.handle); - assert.deepEqual(Buffer.from(text), Buffer.from(await clientResultCanonicalBytes(binding))); + assert.deepEqual(Buffer.from(text), Buffer.from(vector.canonical_bytes)); return sign('sha256', Buffer.from(text), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url'); }, 123, 'fresh-nonce'); assert.deepEqual(result, { command_id: binding.command_id, outcome: binding.outcome, result: binding.visible_state, binding, @@ -47,6 +47,62 @@ test('adapter signs generated bytes with the selected key and returns the genera assert.equal(verifies(await clientResultCanonicalBytes(binding), result.signature.signature), true); }); +test('injected SHA-256 signs every golden vector without global WebCrypto', async () => { + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'crypto'); + Object.defineProperty(globalThis, 'crypto', { configurable: true, value: undefined }); + try { + for (const vector of golden.vectors) { + let hashes = 0; + let signatures = 0; + const result = await signClientResult(vector.input, { person: 'person/test', key: 'key/test', chain: [] }, async text => { + signatures++; + assert.equal(text, vector.canonical_bytes); + return sign('sha256', Buffer.from(text), { key: privateKey, dsaEncoding: 'ieee-p1363' }).toString('base64url'); + }, 123, 'nonce', async bytes => { + hashes++; + assert.equal(new TextDecoder().decode(bytes), vector.canonical_visible_state); + return new Uint8Array(createHash('sha256').update(bytes).digest()); + }); + assert.equal(hashes, 1); + assert.equal(signatures, 1); + assert.equal(verifies(vector.canonical_bytes, result.signature.signature), true); + } + } finally { + if (descriptor) Object.defineProperty(globalThis, 'crypto', descriptor); + else delete globalThis.crypto; + } +}); + +test('invalid Unicode in any generated string refuses the result without signing', async () => { + const binding = golden.vectors.find(vector => vector.input.visible_state.opened_item !== null).input; + const paths = [ + ...['purpose', 'command_kind', 'device', 'session', 'grant_id', 'run_id', 'command_id', 'outcome', 'error_code'].map(field => [field]), + ...['route', 'screen'].map(field => ['visible_state', field]), + ['visible_state', 'visible_item_ids', 0], + ...['id', 'title', 'body'].map(field => ['visible_state', 'opened_item', field]), + ]; + for (const path of paths) for (const invalid of ['\ud800', '\udc00']) { + const input = structuredClone(binding); + let target = input; + for (const key of path.slice(0, -1)) target = target[key]; + target[path.at(-1)] = invalid; + const result = await signClientResult(input, { person: 'person/test', key: 'key/test', chain: [] }, + async () => assert.fail(`signed invalid Unicode at ${path.join('.')}`), 123, 'nonce'); + assert.equal(result, undefined, path.join('.')); + } +}); + +test('hashing and signing failures propagate rather than refusing a result', async () => { + const binding = golden.vectors[0].input; + const signing = { person: 'person/test', key: 'key/test', chain: [] }; + const failure = new Error('digest failed'); + await assert.rejects(signClientResult(binding, signing, async () => assert.fail('signed after digest failure'), + 123, 'nonce', async () => { throw failure; }), error => error === failure); + const signingFailure = new Error('signing failed'); + await assert.rejects(signClientResult(binding, signing, async () => { throw signingFailure; }, + 123, 'nonce'), error => error === signingFailure); +}); + test('revoke awaits the SDK and propagates failure', async () => { let revoked; assert.equal(await revokeClientControl({ async clientTestRevoke(device) { revoked = device; } }, 'device/test'), undefined); diff --git a/apps/ios/clientControlWire.ts b/apps/ios/clientControlWire.ts index c97d93c7aad..ccdd73842ed 100644 --- a/apps/ios/clientControlWire.ts +++ b/apps/ios/clientControlWire.ts @@ -1,27 +1,37 @@ import type { DeviceKey } from '@fractal/views'; import type { FractalClient } from '../../clients/typescript/fractal-client'; -import { clientResultCanonicalBytes, CLIENT_RESULT_SIGNED_FIELDS } from '../../clients/typescript/fractal-client/ClientControl.generated.ts'; +import { clientResultCanonicalBytes, ClientResultCanonicalizationError, CLIENT_RESULT_SIGNED_FIELDS, type ClientControlSha256 } from '../../clients/typescript/fractal-client/ClientControl.generated.ts'; import type { ClientResultBinding, DeviceResult } from '../../clients/typescript/fractal-client/Models.generated.ts'; -/** The native signer receives only the generated, domain-separated result bytes as UTF-8. */ +/** Sign generated UTF-8 bytes; invalid Unicode refuses the result before signing or posting. */ export const signClientResult = async ( binding: ClientResultBinding, signing: DeviceKey & { handle?: string }, sign: (text: string, handle?: string) => Promise, signedAt: number, nonce: string, -): Promise => ({ - command_id: binding.command_id, - outcome: binding.outcome, - ...(binding.error_code === null ? {} : { error_code: binding.error_code }), - result: binding.visible_state, - binding, - signature: { - format: 'fields-v1', signer: signing.person, key: signing.key, chain: signing.chain, - signed_fields: [...CLIENT_RESULT_SIGNED_FIELDS], signed_at_unix_ms: signedAt, nonce, - signature: await sign(new TextDecoder().decode(await clientResultCanonicalBytes(binding)), signing.handle), - }, -}); + sha256?: ClientControlSha256, +): Promise => { + let bytes: Uint8Array; + try { + bytes = await clientResultCanonicalBytes(binding, sha256); + } catch (error) { + if (error instanceof ClientResultCanonicalizationError && error.code === 'invalid-unicode') return undefined; + throw error; + } + return { + command_id: binding.command_id, + outcome: binding.outcome, + ...(binding.error_code === null ? {} : { error_code: binding.error_code }), + result: binding.visible_state, + binding, + signature: { + format: 'fields-v1', signer: signing.person, key: signing.key, chain: signing.chain, + signed_fields: [...CLIENT_RESULT_SIGNED_FIELDS], signed_at_unix_ms: signedAt, nonce, + signature: await sign(new TextDecoder().decode(bytes), signing.handle), + }, + }; +}; export const revokeClientControl = async (client: Pick, device: string): Promise => { await client.clientTestRevoke(device); diff --git a/apps/ios/feed.ts b/apps/ios/feed.ts index e1c5269db90..ac47ae00b09 100644 --- a/apps/ios/feed.ts +++ b/apps/ios/feed.ts @@ -6,7 +6,7 @@ import type { SidebarArrangement } from './sidebarEdits'; import type { HeartbeatSocketFactory } from './collectionSocket'; import type { ConversationHeader, HeaderField } from '@fractal/views'; import { TERMINAL_RESTARTED, withFreshTerminalFence, type Foreground, type TerminalFollowHandlers } from './terminalControls'; -import type { DeviceCommand } from '../../clients/typescript/fractal-client'; +import type { ClientControlGrantLifecycle, DeviceCommand } from '../../clients/typescript/fractal-client'; /** Unwired membership conversion: epoch must come from the daemon capability, never a snapshot ID. */ export const sidebarMembershipFrame = (token: MembershipToken, subscription: string, frame: CollectionFrame, epoch?: string): MembershipEvent | undefined => { @@ -139,7 +139,7 @@ export class Feed { private live = false; private timer: ReturnType | undefined; private stream: CollectionStream | undefined; - private commands: ((command: DeviceCommand) => void) | undefined; + private commands: ((command: DeviceCommand | ClientControlGrantLifecycle, snapshot: boolean) => void) | undefined; private commandsReset: (() => void) | undefined; private cancelOpen: (() => void) | undefined; private windows: Partial> = {}; @@ -185,7 +185,7 @@ export class Feed { } /** Test clients alone may consume commands, on this pairing's existing socket. */ - followCommands(testMode: boolean, granted: boolean, receive: (command: DeviceCommand) => void, reset?: () => void): () => void { + followCommands(testMode: boolean, granted: boolean, receive: (command: DeviceCommand | ClientControlGrantLifecycle, snapshot: boolean) => void, reset?: () => void): () => void { if (!testMode || !granted) return () => {}; this.commands = receive; this.commandsReset = reset; @@ -423,9 +423,9 @@ export class Feed { if ((frame.kind === 'resync' || frame.kind === 'error') && frame.id === 'commands') { this.commandsReset?.(); return; } if ((frame.kind === 'snapshot' || frame.kind === 'changes') && frame.collection === 'commands' && frame.id === 'commands') { if (frame.kind === 'snapshot' || frame.removes.length) this.commandsReset?.(); - for (const command of frame.kind === 'snapshot' ? frame.items : frame.upserts) { - if (command.kind === 'device-command') this.commands?.(command); - } + const rows = frame.kind === 'snapshot' ? frame.items : frame.upserts; + for (const row of rows) if (row.kind === 'client-control-grant-lifecycle') this.commands?.(row, frame.kind === 'snapshot'); + for (const row of rows) if (row.kind === 'device-command') this.commands?.(row, false); return; } // The app holds no ordered-membership window; its windows hold resources. diff --git a/release-notes/ios-client-control.json b/release-notes/ios-client-control.json index 1aa3e983be3..1590b3d849c 100644 --- a/release-notes/ios-client-control.json +++ b/release-notes/ios-client-control.json @@ -1,11 +1,11 @@ { "version": 1, - "summary": "Add capability-gated iOS test-client command admission and canonical signed-result support; native lifecycle integration remains disabled pending codegen", + "summary": "Add test-only capability-gated iOS commands with ordered grant lifecycle, run indicator/revoke, committed visible state and canonical native signed results", "replay": {"status": "none", "detail": "No claim replay changes."}, "schema": {"status": "none", "detail": "No schema changes or handwritten wire models."}, "rules": {"status": "none", "detail": "No checkpoint rule changes."}, "compatibility": {"status": "none", "detail": "Normal apps never subscribe to commands; test-client commands fail closed without an authoritative grant source."}, "downtime": {"status": "none", "detail": "No deployment or native installation."}, - "manual": {"status": "none", "detail": "No pairing or enrollment performed; test-client flag, lifecycle codegen and native SHA-256 adapter are prerequisites."}, + "manual": {"status": "none", "detail": "No pairing or enrollment performed; Expo testClientMode must be true. Native device validation remains a separate gate."}, "recovery": {"status": "changed", "detail": "Test-mode pairing retains its device signing key and optional device/session identity; existing profiles without identity fail closed. Normal pairing is unchanged."} }