From 669766271422576d6b9783567e996793be179bff Mon Sep 17 00:00:00 2001 From: "Oleg.Design" Date: Wed, 28 Oct 2026 20:06:04 +0200 Subject: [PATCH 1/2] fix(touchid): ask for Touch ID explicitly before signing The approval helper relied on an implicit prompt from the Security framework when it signed with an unevaluated LAContext. From the MCP approval chain that prompt never appeared, so the helper blocked until Node killed it after 120 seconds with an empty stderr. setup and approve now call evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics) with the unchanged prompt text, wait at most 90 seconds, then sign with the evaluated context made non-interactive. A whole-operation deadline of 110 seconds keeps every failure below the 120-second approval TTL, with exit status 1 and a message on stderr. No password or software fallback, reuse stays 0, and the key ACL is unchanged. Co-Authored-By: Claude Opus 5.5 --- test/touchid-explicit-prompt.test.mjs | 118 ++++++++++++++++++++++++++ tools/touchid-approve/README.md | 2 +- tools/touchid-approve/main.swift | 57 ++++++++++++- 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 test/touchid-explicit-prompt.test.mjs diff --git a/test/touchid-explicit-prompt.test.mjs b/test/touchid-explicit-prompt.test.mjs new file mode 100644 index 0000000..b4dac04 --- /dev/null +++ b/test/touchid-explicit-prompt.test.mjs @@ -0,0 +1,118 @@ +// Static and Node-side checks for the explicit Touch ID prompt. They never run +// the native helper, create a key, or open a Touch ID window. +import assert from 'node:assert/strict'; +import test from 'node:test'; +import childProcess from 'node:child_process'; +import { generateKeyPairSync } from 'node:crypto'; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { APPROVAL_TTL_MS, TouchIdAnchor, touchIdHelper } from '../src/approval-anchor.mjs'; + +const helperSource = readFileSync(fileURLToPath(new URL('../tools/touchid-approve/main.swift', import.meta.url)), 'utf8'); + +/** Body of `func (` up to its matching brace, skipping string literals. */ +function functionBody(source, name) { + const start = source.indexOf(`func ${name}(`); + if (start < 0) return null; + const open = source.indexOf('{', start); + let depth = 0; + for (let index = open; index < source.length; index++) { + const character = source[index]; + if (character === '"') { + for (index++; index < source.length && source[index] !== '"'; index++) if (source[index] === '\\') index++; + continue; + } + if (character === '{') depth++; + if (character === '}' && --depth === 0) return source.slice(open, index + 1); + } + return null; +} + +/** Returns every reason the helper could sign without an explicit, bounded, biometric-only prompt. */ +function explicitPromptViolations(source) { + const problems = []; + const authenticate = functionBody(source, 'authenticate'); + if (!authenticate) problems.push('authenticate(_:reason:) is missing'); + else { + if (!authenticate.includes('context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason)')) + problems.push('authenticate must evaluate the biometric-only policy with the prompt reason'); + const wait = authenticate.indexOf('finished.wait(timeout: .now() + .seconds(authenticationTimeoutSeconds))'); + if (wait < 0) problems.push('authenticate must wait for the result with a timeout'); + if (!authenticate.includes('context.invalidate()')) problems.push('a timed-out prompt must be invalidated'); + const lock = authenticate.indexOf('context.interactionNotAllowed = true'); + if (lock < 0 || lock < wait) problems.push('the evaluated context must become non-interactive after the prompt'); + } + const timeout = Number(source.match(/^let authenticationTimeoutSeconds = (\d+)$/m)?.[1]); + const deadline = Number(source.match(/^let operationDeadlineSeconds = (\d+)$/m)?.[1]); + if (!(timeout > 0 && timeout < deadline && deadline * 1000 < APPROVAL_TTL_MS)) + problems.push('prompt timeout < operation deadline < approval TTL is required'); + if (!/armOperationDeadline\(\)\n\s+switch CommandLine\.arguments\[1\]/.test(source)) + problems.push('the operation deadline must be armed before setup or approve'); + for (const name of ['approve', 'setup']) { + const body = functionBody(source, name); + if (!body) { problems.push(`${name}() is missing`); continue; } + const prompt = body.indexOf('try authenticate(authentication, reason: reason)'); + if (prompt < 0) { problems.push(`${name}() must call authenticate before using the key`); continue; } + for (const use of ['PrivateKey(dataRepresentation:', 'createPrivateKey(', '.signature(for:']) { + const at = body.indexOf(use); + if (at >= 0 && at < prompt) problems.push(`${name}() uses ${use} before the explicit prompt`); + } + } + for (const required of [ + 'context.touchIDAuthenticationAllowableReuseDuration = 0', + 'context.localizedFallbackTitle = ""', + '[.biometryCurrentSet, .privateKeyUsage]', + 'let reason = "Decision: \\(payload["decision"]!.uppercased())\\nSHA-256: \\(payload["content_hash"]!)\\nRun: \\(promptLabel(payload["run_id"]!))\\nGate: \\(promptLabel(payload["gate_id"]!))"', + 'let reason = "Set up the Touch ID approval key"', + ]) if (!source.includes(required)) problems.push(`missing unchanged requirement: ${required}`); + if (/\.deviceOwnerAuthentication\b(?!WithBiometrics)/.test(source)) problems.push('a password-capable policy is not permitted'); + return problems; +} + +test('approve and setup show an explicit, bounded, biometric-only prompt before using the key', () => { + assert.deepEqual(explicitPromptViolations(helperSource), []); +}); + +test('the static prompt check refuses helpers that rely on an implicit prompt or weaken the policy', () => { + const mutations = { + 'no explicit prompt in approve': helperSource.replace(/(func approve\(\)[\s\S]*?) try authenticate\(authentication, reason: reason\)\n/, '$1'), + 'no explicit prompt in setup': helperSource.replace(/(func setup\(\)[\s\S]*?) try authenticate\(authentication, reason: reason\)\n/, '$1'), + 'prompt after key use': helperSource.replace(/(func approve\(\)[\s\S]*?) try authenticate\(authentication, reason: reason\)\n( let key = .*\n)/, '$1$2 try authenticate(authentication, reason: reason)\n'), + 'password policy': helperSource.replace('evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics,', 'evaluatePolicy(.deviceOwnerAuthentication,'), + 'interactive after prompt': helperSource.replace(' context.interactionNotAllowed = true\n', ''), + 'unbounded wait': helperSource.replace('finished.wait(timeout: .now() + .seconds(authenticationTimeoutSeconds))', 'finished.wait()'), + 'timeout beyond approval TTL': helperSource.replace('let operationDeadlineSeconds = 110', 'let operationDeadlineSeconds = 130'), + 'password fallback title': helperSource.replace('context.localizedFallbackTitle = ""', 'context.localizedFallbackTitle = "Use Password"'), + 'biometric reuse': helperSource.replace('AllowableReuseDuration = 0', 'AllowableReuseDuration = 30'), + 'changed prompt text': helperSource.replace('\\nSHA-256: ', '\\nHash: '), + }; + for (const [name, mutated] of Object.entries(mutations)) { + assert.notEqual(mutated, helperSource, `mutation did not apply: ${name}`); + assert.notDeepEqual(explicitPromptViolations(mutated), [], `check accepted: ${name}`); + } +}); + +test('a failing helper returns its own stderr in the Node approval error', async t => { + const directory = mkdtempSync(join(tmpdir(), 'q-core-touchid-stderr-')); + t.after(() => rmSync(directory, { recursive: true, force: true })); + const failing = join(directory, 'failing-helper'); + writeFileSync(failing, "#!/bin/sh\ncat >/dev/null\necho 'touchid-approve: Touch ID was cancelled. Nothing was signed.' >&2\nexit 1\n"); + chmodSync(failing, 0o700); + const realExecFile = childProcess.execFile; + t.mock.method(childProcess, 'execFile', (file, args, options, callback) => { + assert.equal(file, touchIdHelper()); + assert.deepEqual(args, ['approve']); + return realExecFile(failing, args, options, callback); + }); + const { publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + const anchor = new TouchIdAnchor(publicKey.export({ type: 'spki', format: 'pem' })); + const payload = { run_id: 'run', gate_id: 'gate', content_hash: 'a'.repeat(64), decision: 'approve', + nonce: 'b'.repeat(64), issued_at: new Date().toISOString() }; + await assert.rejects(anchor.confirm(payload), error => { + assert.match(error.message, /Touch ID was cancelled\. Nothing was signed\./); + assert.equal(error.code, 1); + return true; + }); +}); diff --git a/tools/touchid-approve/README.md b/tools/touchid-approve/README.md index 864770d..47f725b 100644 --- a/tools/touchid-approve/README.md +++ b/tools/touchid-approve/README.md @@ -22,6 +22,6 @@ Using this wrapped representation avoids the data protection Keychain persistenc The example hash and nonce are placeholders. The decision is `approve` or `reject`. The helper derives its system Touch ID prompt from the decision, content hash, run and gate in that payload, then signs the exact stdin bytes using ECDSA P-256/SHA-256. It prints JSON containing a base64 X9.62 DER signature. Q-Core verifies the signature and enforces nonce expiry and single use. -Every signing operation uses a fresh authentication context with biometric reuse disabled. Cancellation, unavailable or locked-out Touch ID, and signing errors fail closed. There is no password, Apple Watch, or software-key fallback. The helper does not create a key during `approve`. +Every signing operation uses a fresh authentication context with biometric reuse disabled. Before `setup` or `approve` uses the key, the helper explicitly asks for Touch ID with the same prompt text and waits at most 90 seconds; it then signs with that evaluated, non-interactive context instead of relying on an implicit prompt from the Security framework. The whole operation ends after 110 seconds, below Q-Core's 120-second approval limit. Cancellation, timeout, unavailable or locked-out Touch ID, and signing errors fail closed with exit status 1 and a message on stderr. There is no password, Apple Watch, or software-key fallback. The helper does not create a key during `approve`. Automated tests compile this source when macOS and `swiftc` are available. They never create a key or invoke the Touch ID prompt. The physical Touch ID confirmation requires a manual check on a supported Mac. diff --git a/tools/touchid-approve/main.swift b/tools/touchid-approve/main.swift index 2b9dc68..b5fb4f3 100644 --- a/tools/touchid-approve/main.swift +++ b/tools/touchid-approve/main.swift @@ -45,6 +45,54 @@ func context(reason: String) throws -> LAContext { return context } +// Both limits stay below Q-Core's 120-second approval TTL and helper timeout, so +// the person always gets this helper's own error instead of a silent kill. +let authenticationTimeoutSeconds = 90 +let operationDeadlineSeconds = 110 + +final class AuthenticationOutcome: @unchecked Sendable { + var succeeded = false + var error: Error? +} + +/// Shows the system Touch ID window explicitly and waits for the person. +/// Signing must not depend on an implicit prompt from the Security framework: +/// from a background process chain that prompt may never appear, and the +/// signing call then blocks with no window. The evaluated context is then made +/// non-interactive, so a signing step that is not covered by this evaluation +/// fails at once instead of waiting for a window that is not shown. +func authenticate(_ context: LAContext, reason: String) throws { + let finished = DispatchSemaphore(value: 0) + let outcome = AuthenticationOutcome() + context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { success, error in + outcome.succeeded = success + outcome.error = error + finished.signal() + } + guard finished.wait(timeout: .now() + .seconds(authenticationTimeoutSeconds)) == .success else { + context.invalidate() + throw fail("Touch ID was not confirmed within \(authenticationTimeoutSeconds) seconds. Nothing was signed.") + } + guard outcome.succeeded else { + let detail = outcome.error.map { error -> String in + let code = (error as NSError).code + return code == LAError.userCancel.rawValue || code == LAError.systemCancel.rawValue || code == LAError.appCancel.rawValue + ? "Touch ID was cancelled." : error.localizedDescription + } ?? "Touch ID refused the confirmation." + throw fail("\(detail) Nothing was signed. No password or software fallback is permitted.") + } + context.interactionNotAllowed = true +} + +/// Last-resort guard: a native call that never returns still ends the helper +/// with exit 1 and a message on stderr. +func armOperationDeadline() { + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + .seconds(operationDeadlineSeconds)) { + FileHandle.standardError.write(Data("touchid-approve: The Touch ID operation did not finish within \(operationDeadlineSeconds) seconds. Nothing was signed.\n".utf8)) + _exit(1) + } +} + func keyDirectory(create: Bool) throws -> Int32 { guard let account = getpwuid(geteuid()), let homePointer = account.pointee.pw_dir, let homePath = String(validatingUTF8: homePointer), homePath.hasPrefix("/"), homePath != "/" else { @@ -250,12 +298,15 @@ func setup() throws { input == Data(("{\"nonce\":" + quoteJSON(nonce) + "}").utf8) else { throw fail("Expected one canonical enrollment challenge with a 64-character lowercase hexadecimal nonce.") } - let authentication = try context(reason: "Set up the Touch ID approval key") + let reason = "Set up the Touch ID approval key" + let authentication = try context(reason: reason) defer { authentication.invalidate() } let directory = try keyDirectory(create: true) defer { Darwin.close(directory) } + let existing = try readKeyBlob(directory: directory) + try authenticate(authentication, reason: reason) let key: SecureEnclave.P256.Signing.PrivateKey - if let blob = try readKeyBlob(directory: directory) { + if let blob = existing { key = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: blob, authenticationContext: authentication) } else { key = try createPrivateKey(context: authentication) @@ -278,6 +329,7 @@ func approve() throws { guard let blob = try readKeyBlob(directory: directory) else { throw fail("No Touch ID key is enrolled. Run q-core approver add touchid first.") } + try authenticate(authentication, reason: reason) let key = try SecureEnclave.P256.Signing.PrivateKey(dataRepresentation: blob, authenticationContext: authentication) let signature = try key.signature(for: data) try writeJSON(["signature": signature.derRepresentation.base64EncodedString()]) @@ -285,6 +337,7 @@ func approve() throws { do { guard CommandLine.arguments.count == 2 else { throw fail("Usage: touchid-approve setup|approve") } + armOperationDeadline() switch CommandLine.arguments[1] { case "setup": try setup() case "approve": try approve() From 0736a06dae7db07e61fd0fb375bc18695228f8cb Mon Sep 17 00:00:00 2001 From: "Oleg.Design" Date: Wed, 28 Oct 2026 20:06:04 +0200 Subject: [PATCH 2/2] chore(registry): repin Core after the explicit Touch ID prompt fix Co-Authored-By: Claude Opus 5.5 --- registry/SHA256SUMS | 4 ++-- registry/catalog.json | 6 +++--- registry/catalog.source.json | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/registry/SHA256SUMS b/registry/SHA256SUMS index e1bfbc1..a406a94 100644 --- a/registry/SHA256SUMS +++ b/registry/SHA256SUMS @@ -20,6 +20,7 @@ 507f97a2c179a3cdc1d8093a44771e1a5526d1af2b3f01c11adb7c65a2a7937d components/llm-call-cli.json 52245381f0bea4996183faa6fa6b28a043781ba50c75a293db98e63c52030859 demos/digest-huyiks.txt 52c0af677376ce94b3e5aa91c748d770089c74d9fb0f18245273abf711283caa workflows/wide-fanout.yaml +59294943d2b7ad5f670c9931910e197241c2c0ca74265524bec81e12a3bab2f4 vendor/q-core-0.2.0-q-core.51.tgz 61535abad89a4a72c9984b6993ab69a0c43257daec74d85d3a93a6d7bdcd5fee workflows/json-digest.yaml 624d7425227f48a4ab3d9c10112cb7c91738f811e7631439224fd57dd9ef8d85 demos/digest-multisource.txt 6a850fda4e3cd8d7f4d62ca89a25357679d12453690bfa7cd26937251e741708 components/determined.json @@ -37,9 +38,9 @@ aa4a2c0922a50067dbb55646c69fa76b6a59e7caea387b0b3846d6b32721ac6e workflows/dige b0e18acb16ff7741b03c322cb2f9769be6fbae1b93fc23e7b826c0ed64296050 demos/content-feed.txt b58deb5b87c8ca6f9d81790a5715525022e47a3433e963ab5082be39b03226a6 workflows/podcast-summary.yaml b6eb3bcaceb50de9aefe09fbff3ae96cd17844e5d871e0e222757091c33324a2 workflows/digest.yaml +c06e65d96f29572ddc8f83554c8c638e906a8ba34da7370cc38c17606d3b1491 catalog.json c717a739103fd7820db59f17eb0beddc29b601932d579ec4c8cefd83f2268c06 demos/content-factory.txt c9b13755fe606abc4f339d5d2f34fd80c41caeb274ac161e799f36c68b13c344 components/approval-gate.json -ceaa655b2b695cddbd5ba0479d821e8dd7bf983deb4bc046ca05a74694dd286e catalog.json d0ae5123bfc8cf21859ea97eb7b035fa574dc56141b57411074139a4b7e5cbff demos/wide-fanout.txt d70f4cd8d19b56b72817514040eb646370591147f7b548fc2117542ccaad417e planned.json d897866151680f5b753b1281c060465bbe5077dff91fba2abc540ba830685eda components/llm-call-openrouter.json @@ -53,4 +54,3 @@ f6592d66b2b3304429f25c68eb975d39e4750d507d300ad57f71f32d66147e90 composition.js fb1649002aa05e17e8ad668ba06fd1f60b6b613ab5e3957044b7dd6fd57f9730 components/deduplicate.json fb86702b0df84fec64000d3a398256d2413c862ccd322de9f52312d66a1fc5b5 workflows/sdd-pipeline.yaml fb872479832d9413c238b7df721332fad4327e08680f52b622d597448f30c18c components/specification.json -fc7e1c96163bde6c6366b9516091ed23a23dab981d4176ba291dd3826401f1f6 vendor/q-core-0.2.0-q-core.51.tgz diff --git a/registry/catalog.json b/registry/catalog.json index f852bdd..f334174 100644 --- a/registry/catalog.json +++ b/registry/catalog.json @@ -3862,8 +3862,8 @@ "protocol": "qf.agent/v1", "contractRevision": 17, "artifact": "vendor/q-core-0.2.0-q-core.51.tgz", - "artifactSha256": "fc7e1c96163bde6c6366b9516091ed23a23dab981d4176ba291dd3826401f1f6", - "integrity": "sha512-Q5SgFQ5vm+PZd+n50t9AfI04/KhW4etLetalqA1wR1F6HmTZXIy27OSkbQmJvsVPu2eXvMLmoEiVXuMwK6tTsg==", + "artifactSha256": "59294943d2b7ad5f670c9931910e197241c2c0ca74265524bec81e12a3bab2f4", + "integrity": "sha512-0OU+Bd8rKrRjyLKA5EKWlt1UtSdKvCgIvHyP+L8zfQkES657OlHnieEhdGocA8PbCR7rbStLWXA/31JPTJw73Q==", "schemaSha256": "70c24772b26957f83fa799b9e2ead6f8ca2921c4be987a747adf1138775f6eda", "requestSchemaSha256": "682eb8fe5128bf36f07d5ae1426233b5e19c53bd9b84920c13f186ed8b3d3c6d", "status": "local-candidate-artifact", @@ -4659,5 +4659,5 @@ "limits": "Bound iterations, concurrency, timeouts and retries. Cancellation and failure propagation must be tested." } }, - "releaseSha256": "3c7388c8176eefaced753a9a97c39ee1494b35778c1d5afb843682c44af24521" + "releaseSha256": "d5a6c956ccbbec885f954ab9a60a5a7e43a096ce6840282d8a0ccd1d62b9bf22" } diff --git a/registry/catalog.source.json b/registry/catalog.source.json index a0b0d4e..c7fc033 100644 --- a/registry/catalog.source.json +++ b/registry/catalog.source.json @@ -3781,8 +3781,8 @@ "protocol": "qf.agent/v1", "contractRevision": 17, "artifact": "vendor/q-core-0.2.0-q-core.51.tgz", - "artifactSha256": "fc7e1c96163bde6c6366b9516091ed23a23dab981d4176ba291dd3826401f1f6", - "integrity": "sha512-Q5SgFQ5vm+PZd+n50t9AfI04/KhW4etLetalqA1wR1F6HmTZXIy27OSkbQmJvsVPu2eXvMLmoEiVXuMwK6tTsg==", + "artifactSha256": "59294943d2b7ad5f670c9931910e197241c2c0ca74265524bec81e12a3bab2f4", + "integrity": "sha512-0OU+Bd8rKrRjyLKA5EKWlt1UtSdKvCgIvHyP+L8zfQkES657OlHnieEhdGocA8PbCR7rbStLWXA/31JPTJw73Q==", "schemaSha256": "70c24772b26957f83fa799b9e2ead6f8ca2921c4be987a747adf1138775f6eda", "requestSchemaSha256": "682eb8fe5128bf36f07d5ae1426233b5e19c53bd9b84920c13f186ed8b3d3c6d", "status": "local-candidate-artifact",