diff --git a/.gitignore b/.gitignore index f2c59e24..18966410 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,8 @@ next-env.d.ts # k8s deploy secret (create from deploy/k8s/secret.example.yaml) /deploy/k8s/secret.yaml + +# S/MIME plugin build output (rebuild with: cd vnc/plugins/smime && npm run build) +vnc/plugins/smime/node_modules/ +vnc/plugins/smime/dist/ +vnc/plugins/smime/smime-vnc.zip diff --git a/vnc/VNC-CHANGES.md b/vnc/VNC-CHANGES.md index a00a6875..adae1df0 100644 --- a/vnc/VNC-CHANGES.md +++ b/vnc/VNC-CHANGES.md @@ -54,6 +54,9 @@ microfrontends integration was also added and reverted the same day._ | 2026-08-04 | `vnc/plugins/smime/` (new) | fork of the upstream S/MIME plugin, **source only — upstream `smime.zip` deliberately NOT vendored** | shipped zip is a 1.77 MB bundle at manifest 1.0.1 while source is 1.0.2, so auditing `src/` would not audit what the zip installs. We build from source via `npm run package`. | | 2026-08-04 | `vnc/plugins/smime/src/index.js` | **audit fix 1 (HIGH)** — `maybeAutoImportSigner` now requires `signerEmailMatch === true` and `!selfSigned` before trusting a signer cert | upstream gated on `signatureValid` alone, but `smimeVerify` runs `checkChain:false`, so a self-signed cert asserting any address was silently stored as the ENCRYPTION TARGET for it. Both values were already computed and ignored. | | 2026-08-04 | `vnc/plugins/smime/src/mime-builder.js` | **audit fix 3 (MED-HIGH)** — `stripCrlf()` applied inside `formatHeader` + the 3 directly-assembled headers (`att.contentType`, `att.cid`, `smimeType`) | CRLF escaping reached only Subject and filename; display names, Message-ID, In-Reply-To and References were raw — and those are copied from inbound mail on reply/forward, making it remotely reachable header injection | +| 2026-08-04 | `vnc/plugins/smime/src/smime-decrypt.js` | **audit fix 2 (HIGH)** — content-encryption allowlist (AES-CBC + AES-GCM only, gate runs before any key use); normal decrypt moved to `nativeEngine()` so the liner engine is reachable only for a genuine legacy RSAES-PKCS1-v1_5 key; return `contentAuthenticated` | upstream applied NO algorithm check and ran every decrypt through the liner engine, which registers DES-CBC/3DES-CBC/RC2-CBC for PKCS#12 password encryption — the CMS path inherited them. AEAD-only would break interop (RFC 5751 mandates AES-128-CBC), so CBC stays and the weak ciphers go. | +| 2026-08-04 | `vnc/plugins/smime/src/index.js` | **audit fix 2 (cont.)** — suppress HTML when content is unauthenticated (CBC), text-only, behind new `renderUnauthenticatedHtml` setting (default false) | CMS EnvelopedData has no MAC, so CBC plaintext is malleable and HTML rendering is EFAIL's exfiltration channel. The host blocks remote content by default but that's a setting the plugin can't observe — don't lean on it. Our own encrypt path is always AES-GCM, so outbound mail renders fully. | +| 2026-08-04 | `.gitignore` | ignore `vnc/plugins/smime/{node_modules,dist,smime-vnc.zip}` | build output is reproducible from source; never vendor a prebuilt bundle (that was the upstream mistake) | | 2026-08-04 | `vnc/plugins/smime/manifest.json` | add `auth:observe` | plugin registers `onAfterLogout`/`onAccountSwitch` (real hooks, `lib/plugin-hooks.ts:362-363`) without declaring the permission; under `B-09` the session-key wipe would silently stop running | | 2026-08-04 | `vnc/plugins/smime/verify-fixes.mjs` (new) | 19 regression assertions for both fixes, incl. source checks that fail if a guard is removed | the source assertion caught an interpolated header manual review had wrongly dismissed as static | | 2026-08-04 | `app/(main)/admin/_tabs/plugins.tsx` | **B-01 (UI)** — scanner-findings review panel: holds the rejected file, lists pattern-per-file, offers "Install anyway" / "Cancel"; success message reports how many findings were accepted | without this the override was API-only — an admin uploading a crypto bundle through the web form hit a 400 they could not act on. Also replaces a dead `data.warnings` read (never returned by the route) with the live `findings` field. | diff --git a/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md b/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md index a57353fc..1488b11d 100644 --- a/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md +++ b/vnc/audits/SMIME-PLUGIN-AUDIT-2026-08-04.md @@ -20,13 +20,29 @@ Forked to `vnc/plugins/smime/` — **source only; the upstream zip was deliberat |---|---| | 1 · Certificate substitution | ✅ **Fixed** — auto-import now requires `signerEmailMatch === true` **and** `!selfSigned` | | 3 · CRLF header injection | ✅ **Fixed** — sanitised inside `formatHeader` (covers all 17 call sites) plus the 3 headers assembled directly | +| 2 · Unauthenticated CBC on decrypt | ✅ **Fixed** — content-encryption allowlist + native engine on the mail path + HTML suppressed for unauthenticated plaintext | | — · `auth:observe` | ✅ **Added** to the manifest, so the session-key wipe survives `B-09` | -| 2 · Unauthenticated CBC on decrypt | ⛔ **Open — gate before real mail.** Still accepts unauthenticated CBC | -| 4, 5, 6, 7, 8, 9 | ⛔ Open | +| 4, 5, 6, 7, 8, 9 | ⛔ Open — see the findings table | -Regression tests: `vnc/plugins/smime/verify-fixes.mjs` — 19 assertions, `node vnc/plugins/smime/verify-fixes.mjs`. Covers the attack case for finding 1, CRLF variants for finding 3, and source assertions that fail if either guard is removed or a new unsanitised interpolated header is introduced. That last assertion earned its keep immediately: it caught `smime-type=${input.smimeType}` (`mime-builder.js:216`), which manual review had dismissed as a static string. +Regression tests: `vnc/plugins/smime/verify-fixes.mjs` — **36 assertions**, `node vnc/plugins/smime/verify-fixes.mjs`. Covers the attack case for finding 1, CRLF variants for finding 3, the algorithm allowlist and HTML-suppression decision for finding 2, plus source assertions that fail if any guard is removed or a new unsanitised interpolated header is introduced. That last assertion earned its keep immediately: it caught the interpolated `smime-type` Content-Type header (`mime-builder.js:216`), which manual review had wrongly dismissed as a static string. -**Still not safe for real mail** — finding 2 is unfixed. Suitable only for a throwaway sandbox account. +### How finding 2 was fixed, and why not the obvious way + +The tempting fix — accept only AEAD — would have broken most real S/MIME mail. RFC 5751 makes **AES-128-CBC the MUST-implement** content cipher, Outlook and Thunderbird default to CBC, and AES-GCM in CMS (RFC 5084) is barely deployed. An AEAD-only allowlist is a functionality catastrophe wearing a security fix's clothes. + +Three layers instead: + +1. **Allowlist the AES family, refuse everything else** (`smime-decrypt.js`). CBC stays for interop; DES-CBC (56-bit), 3DES-CBC and RC2-CBC are refused. The gate runs *before any private key is touched*. +2. **Take the mail path off the legacy engine.** Those weak OIDs are registered in `crypto-engine.js` for PKCS#12 *password-based* encryption; the CMS content path merely reused the same engine and inherited them. Normal decryption now uses `nativeEngine()`, and the liner engine is reachable only when a legacy RSAES-PKCS1-v1_5 key is genuinely in play. This removes the weak ciphers **structurally**, not just by policy. +3. **Refuse to render unauthenticated plaintext as HTML.** CBC output is malleable, and HTML rendering is EFAIL's exfiltration channel. The host does block remote content by default (`allowExternalContent` starts `false`, `email-viewer.tsx:780`) — but that is a user/admin setting the plugin cannot observe, so we don't lean on it. `renderUnauthenticatedHtml` (default **false**) is the documented opt-out. Our own encrypt path always uses AES-GCM, so mail we send renders fully; only legacy inbound CBC mail degrades to text. + +### Build provenance + +Built from the forked source with the repo's own pipeline (`npm run build` → esbuild → `dist/index.js`, 1.69 MB) and packaged to `smime-vnc.zip` (0.27 MB, well under the 5 MB ceiling). All four fixes verified present in the built bundle. + +Worth noting against an earlier assumption: **this bundle does not trip the `B-01` pattern scanner** — zero matches on all five patterns. `B-01` remains correct (it closed a real entrypoint-only coverage gap, and openpgp.js for the PGP plugin may yet need the override) but it is not required to install this plugin. + +**Remaining risk:** findings 4 (unlocked keys persisted to IndexedDB), 5 (parser DoS) and 6 (PKCS1v1.5 oracle surface) are open. Suitable for sandbox use; findings 4 and 5 should be closed before real mailboxes. ## Findings diff --git a/vnc/plugins/smime/manifest.json b/vnc/plugins/smime/manifest.json index 491c3bfb..704d5462 100644 --- a/vnc/plugins/smime/manifest.json +++ b/vnc/plugins/smime/manifest.json @@ -44,6 +44,12 @@ "description": "Wipe all unlocked private keys from memory when you sign out or switch accounts. Leave on unless you have a specific reason not to.", "default": true }, + "renderUnauthenticatedHtml": { + "type": "boolean", + "label": "Render HTML in legacy-encrypted mail", + "description": "Messages encrypted with AES-CBC carry no integrity protection, so their contents can be tampered with in transit. By default such mail is shown as plain text, which prevents a known attack that can leak the decrypted message. Turn this on only if you need HTML rendering for older encrypted mail and accept that risk. Mail encrypted with AES-GCM is unaffected and always renders fully.", + "default": false + }, "warnOnSelfSigned": { "type": "boolean", "label": "Warn on self-signed signer", diff --git a/vnc/plugins/smime/package-lock.json b/vnc/plugins/smime/package-lock.json index a08d283b..e1000cf7 100644 --- a/vnc/plugins/smime/package-lock.json +++ b/vnc/plugins/smime/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulwark-plugin-smime", - "version": "1.0.0", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulwark-plugin-smime", - "version": "1.0.0", + "version": "1.0.2", "dependencies": { "asn1js": "^3.0.10", "pkijs": "^3.4.0", diff --git a/vnc/plugins/smime/src/index.js b/vnc/plugins/smime/src/index.js index 66c97f3f..5f782e2a 100644 --- a/vnc/plugins/smime/src/index.js +++ b/vnc/plugins/smime/src/index.js @@ -495,7 +495,15 @@ async function onRenderEmailBody(body, ctx) { // MIME entity (RFC 8551 sign-then-encrypt, the Outlook/Thunderbird form) // or, more rarely, raw CMS DER. Detect both. let innerBytes = result.mimeBytes; - const verification = { isEncrypted: true, decryptionSuccess: true }; + const verification = { + isEncrypted: true, + decryptionSuccess: true, + // VNC (audit finding 2): CMS EnvelopedData carries no MAC, so only AEAD + // content encryption yields authenticated plaintext. Surface it so the + // banner can say so rather than implying all decrypted mail is equal. + contentAuthenticated: result.contentAuthenticated, + contentAlgorithm: result.contentAlgorithm, + }; const innerCt = innerContentType(innerBytes); const innerDet = detectSmime(innerCt, null, null); const looksSigned = innerDet.type === 'signed-data' || innerBytes[0] === 0x30; @@ -511,13 +519,33 @@ async function onRenderEmailBody(body, ctx) { const parsed = parseMime(innerBytes); await persistVerifyStatus(ctx.id, verification); + + // VNC (audit finding 2): unauthenticated (CBC) plaintext is malleable, so + // rendering it as HTML is the EFAIL exfiltration channel — an attacker who + // holds the ciphertext can splice in a gadget that leaks the plaintext via + // an external resource load. The host does block remote content by default + // (`allowExternalContent` starts false), but that is a user/admin setting + // this plugin cannot see, so we do not lean on it. + // + // Suppressing HTML and rendering text only closes the channel regardless of + // host configuration. Our own encrypt path always uses AES-GCM, so mail we + // send renders fully; this only degrades legacy inbound CBC mail, and the + // banner explains why. `renderUnauthenticatedHtml` is the documented opt-out. + const allowUnauthHtml = settings().renderUnauthenticatedHtml === true; + const suppressHtml = !result.contentAuthenticated && !allowUnauthHtml; + if (suppressHtml && parsed.html) { + host.log.warn( + `rendering text-only: ${result.contentAlgorithm} is not authenticated (EFAIL mitigation)`, + ); + } + return { ...body, handledBy: 'smime', - html: parsed.html || '', + html: suppressHtml ? '' : (parsed.html || ''), text: parsed.text || '', attachments: parsed.attachments, - verification, + verification: { ...verification, htmlSuppressed: suppressHtml }, }; } diff --git a/vnc/plugins/smime/src/smime-decrypt.js b/vnc/plugins/smime/src/smime-decrypt.js index 2dad22df..23af83f5 100644 --- a/vnc/plugins/smime/src/smime-decrypt.js +++ b/vnc/plugins/smime/src/smime-decrypt.js @@ -6,9 +6,58 @@ import * as pkijs from 'pkijs'; import * as asn1js from 'asn1js'; -import { getLinerCryptoEngine, withLinerEngine } from './crypto-engine.js'; +import { getLinerCryptoEngine, withLinerEngine, nativeEngine } from './crypto-engine.js'; import { arraysEqual, toHex } from './util.js'; +// ─── VNC: content-encryption allowlist (audit finding 2) ─────────────── +// +// Upstream applied NO algorithm check on decrypt and ran every decryption +// through the liner engine, which deliberately widens the accepted set to +// DES-CBC (56-bit), 3DES-CBC and RC2-CBC. Those OIDs exist in crypto-engine.js +// for PKCS#12 *password-based* encryption; the CMS content path reused the same +// engine and inherited them, so a crafted message could be decrypted under a +// broken cipher. +// +// The tempting fix — accept only AEAD — would break most real S/MIME mail. +// RFC 5751 makes AES-128-CBC the MUST-implement content cipher and both Outlook +// and Thunderbird default to CBC; AES-GCM in CMS (RFC 5084) is barely deployed. +// An AEAD-only allowlist would be a functionality catastrophe wearing a security +// fix's clothes. +// +// So: allow the AES family (CBC for interop, GCM preferred), refuse everything +// else, and tell the caller whether what it got was actually authenticated. +// CMS EnvelopedData carries no MAC, so CBC output is malleable — that is the +// EFAIL precondition, and the real mitigation is refusing to render +// unauthenticated plaintext as HTML with external resources. `contentAuthenticated` +// is what lets the render path make that decision instead of guessing. +const CONTENT_ENCRYPTION_ALLOWLIST = new Map([ + ['2.16.840.1.101.3.4.1.2', { name: 'AES-128-CBC', authenticated: false }], + ['2.16.840.1.101.3.4.1.22', { name: 'AES-192-CBC', authenticated: false }], + ['2.16.840.1.101.3.4.1.42', { name: 'AES-256-CBC', authenticated: false }], + ['2.16.840.1.101.3.4.1.6', { name: 'AES-128-GCM', authenticated: true }], + ['2.16.840.1.101.3.4.1.26', { name: 'AES-192-GCM', authenticated: true }], + ['2.16.840.1.101.3.4.1.46', { name: 'AES-256-GCM', authenticated: true }], +]); + +/** + * Refuse content-encryption algorithms outside the allowlist, before any + * decryption is attempted. Returns the matched descriptor. + */ +function checkContentEncryption(envelopedData) { + const oid = envelopedData?.encryptedContentInfo?.contentEncryptionAlgorithm?.algorithmId; + if (!oid) throw new Error('Encrypted message has no content-encryption algorithm'); + const allowed = CONTENT_ENCRYPTION_ALLOWLIST.get(oid); + if (!allowed) { + // Deliberately refuse rather than fall through — a message asking to be + // decrypted under DES/RC2 in 2026 is not a message we want to read. + throw new Error( + `Refusing to decrypt: unsupported or insecure content-encryption algorithm (${oid}). ` + + 'Only AES-CBC and AES-GCM are accepted.', + ); + } + return allowed; +} + export class SmimeKeyLockedError extends Error { constructor(message, keyRecordId) { super(message); @@ -28,19 +77,32 @@ export async function smimeDecrypt(input) { const contentInfo = parseContentInfo(cmsBytes); const envelopedData = extractEnvelopedData(contentInfo); + // VNC: gate the algorithm BEFORE touching any private key, so a message using + // a refused cipher never reaches a decrypt primitive at all. + const contentAlg = checkContentEncryption(envelopedData); + const matchedRecords = findMatchingKeyRecords(envelopedData, keyRecords); if (matchedRecords.length === 0) { throw new Error('No imported S/MIME key matches any recipient in this encrypted message'); } + const result = (decrypted, keyRecord) => ({ + mimeBytes: new Uint8Array(decrypted), + keyRecordId: keyRecord.id, + // VNC: true only for AEAD content encryption. The caller must not render + // unauthenticated plaintext as HTML with external resources (EFAIL). + contentAuthenticated: contentAlg.authenticated, + contentAlgorithm: contentAlg.name, + }); + for (const { keyRecord, recipientIndex } of matchedRecords) { const privateKey = unlockedKeys.get(keyRecord.id); if (!privateKey) { const legacyKey = legacyUnlockedKeys?.get(keyRecord.id); if (legacyKey) { try { - const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord); - return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id }; + const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord, true); + return result(decrypted, keyRecord); } catch { continue; } @@ -49,14 +111,14 @@ export async function smimeDecrypt(input) { } try { - const decrypted = await decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord); - return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id }; + const decrypted = await decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord, false); + return result(decrypted, keyRecord); } catch { const legacyKey = legacyUnlockedKeys?.get(keyRecord.id); if (legacyKey) { try { - const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord); - return { mimeBytes: new Uint8Array(decrypted), keyRecordId: keyRecord.id }; + const decrypted = await decryptWithKey(envelopedData, recipientIndex, legacyKey, keyRecord, true); + return result(decrypted, keyRecord); } catch { /* try next record */ } @@ -255,16 +317,27 @@ function matchesKeyTransRecipient(recipientInfo, keyRecord) { return false; } -async function decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord) { +/** + * @param useLiner Only for the legacy RSAES-PKCS1-v1_5 key-transport key, which + * is imported through webcrypto-liner and can only be used by that engine. + * + * VNC: upstream ran EVERY decryption through the liner engine, which is what put + * DES-CBC/3DES-CBC/RC2-CBC within reach of live mail — those OIDs are registered + * for PKCS#12 password-based encryption, not CMS content encryption. Native + * WebCrypto handles RSA-OAEP key transport and AES-CBC/GCM content perfectly + * well, so the normal path now uses the native engine and the legacy engine is + * reachable only when a legacy key is genuinely in play. Combined with + * checkContentEncryption() this removes the weak ciphers structurally, not just + * by policy. + */ +async function decryptWithKey(envelopedData, recipientIndex, privateKey, keyRecord, useLiner) { const certAsn1 = asn1js.fromBER(keyRecord.certificate); const cert = new pkijs.Certificate({ schema: certAsn1.result }); + const params = { recipientCertificate: cert, recipientPrivateKey: privateKey }; - return withLinerEngine(async () => { - const cryptoEngine = getLinerCryptoEngine(); - return envelopedData.decrypt( - recipientIndex, - { recipientCertificate: cert, recipientPrivateKey: privateKey }, - cryptoEngine, - ); - }); + if (!useLiner) { + return envelopedData.decrypt(recipientIndex, params, nativeEngine()); + } + + return withLinerEngine(async () => envelopedData.decrypt(recipientIndex, params, getLinerCryptoEngine())); } diff --git a/vnc/plugins/smime/verify-fixes.mjs b/vnc/plugins/smime/verify-fixes.mjs index 7fe86fb2..c8258627 100644 --- a/vnc/plugins/smime/verify-fixes.mjs +++ b/vnc/plugins/smime/verify-fixes.mjs @@ -54,6 +54,34 @@ check('multiple injected headers', check('clean value untouched', stripCrlf('Normal Subject'), 'Normal Subject'); check('non-ASCII untouched', stripCrlf('Grüße büro'), 'Grüße büro'); +// ── Finding 2: content-encryption allowlist ───────────────────────── +const ALLOW = new Map([ + ['2.16.840.1.101.3.4.1.2', { name: 'AES-128-CBC', authenticated: false }], + ['2.16.840.1.101.3.4.1.22', { name: 'AES-192-CBC', authenticated: false }], + ['2.16.840.1.101.3.4.1.42', { name: 'AES-256-CBC', authenticated: false }], + ['2.16.840.1.101.3.4.1.6', { name: 'AES-128-GCM', authenticated: true }], + ['2.16.840.1.101.3.4.1.26', { name: 'AES-192-GCM', authenticated: true }], + ['2.16.840.1.101.3.4.1.46', { name: 'AES-256-GCM', authenticated: true }], +]); +const accepts = (oid) => ALLOW.has(oid); +const authed = (oid) => ALLOW.get(oid)?.authenticated ?? null; + +console.log('\nFinding 2 — content-encryption allowlist'); +check('AES-256-GCM accepted', accepts('2.16.840.1.101.3.4.1.46'), true); +check('AES-256-GCM is authenticated', authed('2.16.840.1.101.3.4.1.46'), true); +check('AES-128-CBC accepted (RFC 5751 interop)', accepts('2.16.840.1.101.3.4.1.2'), true); +check('AES-128-CBC NOT authenticated', authed('2.16.840.1.101.3.4.1.2'), false); +check('3DES-CBC REFUSED', accepts('1.2.840.113549.3.7'), false); +check('DES-CBC REFUSED', accepts('1.3.14.3.2.7'), false); +check('RC2-CBC REFUSED', accepts('1.2.840.113549.3.2'), false); +check('unknown OID REFUSED', accepts('1.2.3.4.5'), false); + +// HTML suppression decision (EFAIL mitigation) +const suppress = (contentAuthenticated, optOut = false) => !contentAuthenticated && !optOut; +check('GCM -> HTML rendered', suppress(true), false); +check('CBC -> HTML suppressed by default', suppress(false), true); +check('CBC + explicit opt-out -> HTML rendered', suppress(false, true), false); + // ── Source assertions: guard against silent regression ────────────── console.log('\nSource assertions'); const idx = readFileSync(join(here, 'src/index.js'), 'utf8'); @@ -69,5 +97,18 @@ const bypass = [...mb.matchAll(/lines\.push\(`([A-Za-z-]+): ([^`]*)`\)/g)] check('no unsanitised interpolated headers remain', bypass.length, 0); if (bypass.length) bypass.forEach(([m]) => console.log(' >>', m)); +const dec = readFileSync(join(here, 'src/smime-decrypt.js'), 'utf8'); +check('allowlist gate runs before any key use', + dec.indexOf('checkContentEncryption(envelopedData)') < dec.indexOf('unlockedKeys.get'), true); +check('decrypt refuses non-allowlisted algorithms', /Refusing to decrypt/.test(dec), true); +check('no legacy CBC OID appears in the decrypt allowlist', + /1\.2\.840\.113549\.3\.7|1\.3\.14\.3\.2\.7|1\.2\.840\.113549\.3\.2/.test(dec), false); +check('normal decrypt path uses the NATIVE engine', + /if \(!useLiner\)[\s\S]{0,120}nativeEngine\(\)/.test(dec), true); +check('liner engine reachable only via useLiner', + (dec.match(/getLinerCryptoEngine\(\)/g) || []).length, 1); +check('index.js suppresses HTML for unauthenticated content', + idx.includes('suppressHtml') && idx.includes('result.contentAuthenticated'), true); + console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'} — ${pass} passed, ${fail} failed\n`); process.exit(fail === 0 ? 0 : 1);