Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions registry/SHA256SUMS
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
6 changes: 3 additions & 3 deletions registry/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -4659,5 +4659,5 @@
"limits": "Bound iterations, concurrency, timeouts and retries. Cancellation and failure propagation must be tested."
}
},
"releaseSha256": "3c7388c8176eefaced753a9a97c39ee1494b35778c1d5afb843682c44af24521"
"releaseSha256": "d5a6c956ccbbec885f954ab9a60a5a7e43a096ce6840282d8a0ccd1d62b9bf22"
}
4 changes: 2 additions & 2 deletions registry/catalog.source.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
118 changes: 118 additions & 0 deletions test/touchid-explicit-prompt.test.mjs
Original file line number Diff line number Diff line change
@@ -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 <name>(` 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;
});
});
2 changes: 1 addition & 1 deletion tools/touchid-approve/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
57 changes: 55 additions & 2 deletions tools/touchid-approve/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand All @@ -278,13 +329,15 @@ 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()])
}

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()
Expand Down
Loading