[object Object]

← back to Costa Rica

costa-rica: test coverage for security-critical untested code — Sign-in-with-Apple identity-token verifier (offline RSA+stubbed JWKS: signature, iss/aud/exp, and the c2 email_verified account-takeover guard) + E.164 normalizePhone (exported for test); 13 new tests, suite 42/42 green — TK-10346

4c5d52141ac5a44d6fcb6fc5fcc584c3cea63562 · 2026-08-07 13:25:01 -0700 · Steve

Files touched

Diff

commit 4c5d52141ac5a44d6fcb6fc5fcc584c3cea63562
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 13:25:01 2026 -0700

    costa-rica: test coverage for security-critical untested code — Sign-in-with-Apple identity-token verifier (offline RSA+stubbed JWKS: signature, iss/aud/exp, and the c2 email_verified account-takeover guard) + E.164 normalizePhone (exported for test); 13 new tests, suite 42/42 green — TK-10346
---
 routes/app.js         |  2 +-
 test/apple.test.js    | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++
 test/contacts.test.js | 32 ++++++++++++++++++++
 3 files changed, 117 insertions(+), 1 deletion(-)

diff --git a/routes/app.js b/routes/app.js
index 5426009..41772a3 100644
--- a/routes/app.js
+++ b/routes/app.js
@@ -453,4 +453,4 @@ router.post('/host/plaid/exchange', authRequired, async (req, res) => {
   ok(res, { payout_method_id: pm.id, last4: acct.mask, sandbox: !!ex.sandbox });
 });
 
-module.exports = { router, confirmBooking };
+module.exports = { router, confirmBooking, normalizePhone };
diff --git a/test/apple.test.js b/test/apple.test.js
new file mode 100644
index 0000000..a623b41
--- /dev/null
+++ b/test/apple.test.js
@@ -0,0 +1,84 @@
+'use strict';
+// Offline tests (node:test) for the Sign in with Apple identity-token verifier.
+// No network: we generate our own RSA keypair, stub Apple's JWKS endpoint with it,
+// and sign our own tokens. Guards the c2 account-takeover fix — email_verified must
+// be a real boolean, never a truthy string smuggled in.
+// Run: node --test
+const { test, before } = require('node:test');
+const assert = require('node:assert');
+const crypto = require('crypto');
+
+// aud must match apple.js's default (process.env.APPLE_AUD || 'com.abrams.costarica')
+const AUD = 'com.abrams.costarica';
+const ISS = 'https://appleid.apple.com';
+const KID = 'test-key-1';
+
+const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+const jwk = { ...publicKey.export({ format: 'jwk' }), kid: KID, alg: 'RS256', use: 'sig' };
+
+const b64u = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
+
+function signToken(payload, { kid = KID } = {}) {
+  const h = b64u({ alg: 'RS256', kid });
+  const p = b64u({ iss: ISS, aud: AUD, exp: Math.floor(Date.now() / 1000) + 600, ...payload });
+  const sig = crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), privateKey).toString('base64url');
+  return `${h}.${p}.${sig}`;
+}
+
+let verifyIdentityToken;
+before(() => {
+  // Stub Apple's JWKS endpoint with our test key BEFORE requiring the module.
+  global.fetch = async () => ({ ok: true, json: async () => ({ keys: [jwk] }) });
+  ({ verifyIdentityToken } = require('../lib/apple'));
+});
+
+test('valid token returns sub + email + email_verified', async () => {
+  const claims = await verifyIdentityToken(signToken({ sub: 'apple-001', email: 'a@b.com', email_verified: 'true' }));
+  assert.equal(claims.sub, 'apple-001');
+  assert.equal(claims.email, 'a@b.com');
+  assert.equal(claims.email_verified, true);
+});
+
+test('email_verified normalizes both the string "true" and boolean true → true', async () => {
+  assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: 'true' }))).email_verified, true);
+  assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: true }))).email_verified, true);
+});
+
+test('SECURITY: email_verified "false"/missing is false (blocks c2 account-takeover auto-link)', async () => {
+  assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: 'false' }))).email_verified, false);
+  assert.equal((await verifyIdentityToken(signToken({ sub: 's', email_verified: false }))).email_verified, false);
+  assert.equal((await verifyIdentityToken(signToken({ sub: 's' }))).email_verified, false); // absent
+});
+
+test('rejects a tampered signature', async () => {
+  const tok = signToken({ sub: 's' });
+  const bad = tok.slice(0, -3) + (tok.slice(-3) === 'AAA' ? 'BBB' : 'AAA');
+  await assert.rejects(() => verifyIdentityToken(bad), /signature invalid/);
+});
+
+test('rejects a wrong audience', async () => {
+  const h = b64u({ alg: 'RS256', kid: KID });
+  const p = b64u({ iss: ISS, aud: 'com.someone.else', exp: Math.floor(Date.now() / 1000) + 600, sub: 's' });
+  const sig = crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), privateKey).toString('base64url');
+  await assert.rejects(() => verifyIdentityToken(`${h}.${p}.${sig}`), /bad aud/);
+});
+
+test('rejects a wrong issuer', async () => {
+  const h = b64u({ alg: 'RS256', kid: KID });
+  const p = b64u({ iss: 'https://evil.example', aud: AUD, exp: Math.floor(Date.now() / 1000) + 600, sub: 's' });
+  const sig = crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), privateKey).toString('base64url');
+  await assert.rejects(() => verifyIdentityToken(`${h}.${p}.${sig}`), /bad iss/);
+});
+
+test('rejects an expired token', async () => {
+  const h = b64u({ alg: 'RS256', kid: KID });
+  const p = b64u({ iss: ISS, aud: AUD, exp: Math.floor(Date.now() / 1000) - 60, sub: 's' });
+  const sig = crypto.sign('RSA-SHA256', Buffer.from(`${h}.${p}`), privateKey).toString('base64url');
+  await assert.rejects(() => verifyIdentityToken(`${h}.${p}.${sig}`), /expired/);
+});
+
+test('rejects a malformed token and an unknown signing key', async () => {
+  await assert.rejects(() => verifyIdentityToken('not.a.jwt.at.all'), /malformed/);
+  await assert.rejects(() => verifyIdentityToken('abc'), /malformed/);
+  await assert.rejects(() => verifyIdentityToken(signToken({ sub: 's' }, { kid: 'unknown-kid' })), /signing key not found/);
+});
diff --git a/test/contacts.test.js b/test/contacts.test.js
new file mode 100644
index 0000000..0534548
--- /dev/null
+++ b/test/contacts.test.js
@@ -0,0 +1,32 @@
+'use strict';
+// Zero-dependency tests (node:test) for E.164 phone normalization used by the
+// contacts + account features. Costa Rica default (+506) for bare 8-digit locals.
+// Run: node --test
+const { test } = require('node:test');
+const assert = require('node:assert');
+const { normalizePhone } = require('../routes/app');
+
+test('normalizePhone: bare 8-digit Costa Rica local gets +506', () => {
+  assert.equal(normalizePhone('8888 8888'), '+50688888888');
+  assert.equal(normalizePhone('2222-3333'), '+50622223333');
+});
+
+test('normalizePhone: 11-digit 506-prefixed number gets a leading +', () => {
+  assert.equal(normalizePhone('50688887777'), '+50688887777');
+});
+
+test('normalizePhone: an already-E.164 (+) number is preserved', () => {
+  assert.equal(normalizePhone('+50688887777'), '+50688887777');
+  assert.equal(normalizePhone('+1 (818) 373-4564'), '+18183734564');
+});
+
+test('normalizePhone: a 10+ digit foreign number gets a leading +', () => {
+  assert.equal(normalizePhone('18183734564'), '+18183734564');
+});
+
+test('normalizePhone: unparseable / too-short / empty input is null (no bogus number stored)', () => {
+  assert.equal(normalizePhone(''), null);
+  assert.equal(normalizePhone(null), null);
+  assert.equal(normalizePhone('123'), null);      // too short, not CR-local length
+  assert.equal(normalizePhone('abc'), null);      // no digits
+});

← 5408fdb yoloforever: cycle 3 ledger — sort+density shipped + Cody-ga  ·  back to Costa Rica  ·  costa-rica: add read-only prod smoke test + gitignored .depl f1ed844 →