diff --git a/scratchpad/d1_atrest.js b/scratchpad/d1_atrest.js new file mode 100644 index 00000000..aa1b7561 --- /dev/null +++ b/scratchpad/d1_atrest.js @@ -0,0 +1,59 @@ +// D1 at-rest ciphertext verifier (READ-ONLY). Prints only field -> enc✓ / NOT-ENC + length. +// NEVER prints decrypted/plaintext content (per the QA logging rule). +const admin = require('firebase-admin') +const path = require('path') +const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') +const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' +admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) +const db = admin.firestore() + +const CIPHER = /^(enc:v1:|sealed:v1:)/ +// fields that MUST be ciphertext if present +const PRIVATE_FIELDS = ['text','content','lastMessagePreview','title','description','duration','note','message','answer','body','preview'] + +function checkVal(v) { + if (typeof v !== 'string') return null + if (CIPHER.test(v)) return `enc✓(${v.length})` + // base64 Tink media or short metadata? flag anything that isn't enc/sealed + return `NOT-ENC?(len=${v.length})` +} + +async function sampleColl(ref, label, limit = 3) { + try { + const snap = await ref.limit(limit).get() + if (snap.empty) { console.log(` [${label}] empty`); return } + let i = 0 + snap.forEach(doc => { + if (i++ >= limit) return + const d = doc.data() + const hits = [] + for (const f of PRIVATE_FIELDS) { + if (d[f] !== undefined) hits.push(`${f}=${checkVal(d[f])}`) + } + console.log(` [${label}] ${doc.id}: ${hits.length ? hits.join(' ') : '(no private fields)'}`) + }) + } catch (e) { console.log(` [${label}] ERR ${e.code || e.message}`) } +} + +(async () => { + console.log('=== D1 at-rest check, couple', COUPLE, '===') + // 1) couple doc subcollections + const coupleRef = db.collection('couples').doc(COUPLE) + const subs = await coupleRef.listCollections().catch(() => []) + console.log('couple subcollections:', subs.map(s => s.id).join(', ') || '(none)') + for (const s of subs) await sampleColl(s, `couples/${COUPLE}/${s.id}`) + // 2) common top-level collections keyed by coupleId + for (const c of ['messages','conversations','answers','bucket_list','date_plans','date_swipes','memory_capsules','capsules','date_plan_preferences','game_sessions']) { + const ref = db.collection(c).where('coupleId','==',COUPLE) + await sampleColl(ref, c) + } + // 3) conversations -> messages (nested) + try { + const convs = await db.collection('conversations').where('coupleId','==',COUPLE).limit(2).get() + for (const cv of convs.docs) { + await sampleColl(cv.ref.collection('messages'), `conversations/${cv.id}/messages`) + } + } catch (e) { console.log(' conv->messages ERR', e.code || e.message) } + console.log('=== done ===') + process.exit(0) +})().catch(e => { console.error('FATAL', e); process.exit(1) }) diff --git a/scratchpad/d1_probe2.js b/scratchpad/d1_probe2.js new file mode 100644 index 00000000..e346cdd0 --- /dev/null +++ b/scratchpad/d1_probe2.js @@ -0,0 +1,42 @@ +// D1 deep probe: dump FIELD NAMES + per-string enc-status/length (NEVER values). +const admin = require('firebase-admin') +const path = require('path') +const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') +const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' +admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) +const db = admin.firestore() +const CIPHER = /^(enc:v1:|sealed:v1:)/ +const MEDIA = /^(01|AAAA|CiQ|Ei)/ // tink/base64-ish prefixes seen for media + +function fieldReport(d) { + return Object.entries(d).map(([k, v]) => { + if (typeof v === 'string') { + const tag = CIPHER.test(v) ? 'enc✓' : (v.length > 40 ? `RAWSTR?(${v.length})` : `meta(${v.length})`) + return `${k}:${tag}` + } + if (Array.isArray(v)) return `${k}:[${v.length}]${v.every(x => typeof x === 'string' && CIPHER.test(x)) && v.length ? 'enc✓' : ''}` + if (v && typeof v === 'object') return `${k}:{${Object.keys(v).join(',')}}` + return `${k}:${typeof v}` + }).join(' ') +} + +async function dump(ref, label, n = 2) { + try { + const s = await ref.limit(n).get() + if (s.empty) return console.log(`[${label}] empty`) + s.docs.slice(0, n).forEach(doc => console.log(`[${label}] ${doc.id}\n ${fieldReport(doc.data())}`)) + } catch (e) { console.log(`[${label}] ERR ${e.code || e.message}`) } +} + +(async () => { + const c = db.collection('couples').doc(COUPLE) + // messages nested under each conversation + const convs = await c.collection('conversations').limit(3).get() + for (const cv of convs.docs) { + await dump(cv.ref.collection('messages'), `conv:${cv.id}/messages`, 2) + } + for (const g of ['this_or_that','desire_sync','how_well','wheel','date_swipes','sessions']) { + await dump(c.collection(g), g, 1) + } + process.exit(0) +})().catch(e => { console.error('FATAL', e); process.exit(1) }) diff --git a/scratchpad/d1_probe3.js b/scratchpad/d1_probe3.js new file mode 100644 index 00000000..37b79644 --- /dev/null +++ b/scratchpad/d1_probe3.js @@ -0,0 +1,33 @@ +// D1 nested-answer probe: for game answers map, report each uid's answer value enc-status (NEVER values). +const admin = require('firebase-admin') +const path = require('path') +const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') +const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' +admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) +const db = admin.firestore() +const CIPHER = /^(enc:v1:|sealed:v1:)/ + +function classify(v) { + if (typeof v === 'string') return CIPHER.test(v) ? `enc✓(${v.length})` : `RAW?(${v.length})` + if (Array.isArray(v)) return `[${v.length}: ${v.map(classify).join(',')}]` + if (v && typeof v === 'object') return '{' + Object.entries(v).map(([k, x]) => `${k}=${classify(x)}`).join(' ') + '}' + return typeof v +} + +async function probe(coll, mapField) { + const c = db.collection('couples').doc(COUPLE) + const s = await c.collection(coll).limit(1).get() + if (s.empty) return console.log(`[${coll}] empty`) + const d = s.docs[0].data() + const m = d[mapField] + if (!m) return console.log(`[${coll}] no ${mapField}`) + for (const [uid, val] of Object.entries(m)) { + console.log(`[${coll}] ${mapField}.${uid.slice(0, 6)}…: ${classify(val)}`) + } +} + +(async () => { + for (const g of ['this_or_that', 'desire_sync', 'how_well', 'wheel']) await probe(g, 'answers') + await probe('date_swipes', 'actions') + process.exit(0) +})().catch(e => { console.error('FATAL', e); process.exit(1) }) diff --git a/scratchpad/d3_negative.js b/scratchpad/d3_negative.js new file mode 100644 index 00000000..d831f33e --- /dev/null +++ b/scratchpad/d3_negative.js @@ -0,0 +1,53 @@ +// D3 negative access (LIVE): mint a NON-MEMBER token → raw Firestore REST → expect 403/PERMISSION_DENIED. +const admin = require('firebase-admin') +const path = require('path') +const SA = process.env.SA_JSON || path.join(__dirname, '..', 'closer-app-22014-firebase-adminsdk-fbsvc-ed20bf6003.json') +const PROJECT = 'closer-app-22014' +const APIKEY = 'AIzaSyDAD7FnEYzhMsil41SzJ1XMjUNnJWmjie8' +const COUPLE = process.env.COUPLE_ID || 'Xal3Kw3gjSdn0niERYKJ' +const FAKE_UID = 'qa-nonmember-' + Date.now() +admin.initializeApp({ credential: admin.credential.cert(require(SA)) }) + +const docBase = `https://firestore.googleapis.com/v1/projects/${PROJECT}/databases/(default)/documents` + +async function getIdToken() { + const custom = await admin.auth().createCustomToken(FAKE_UID) + const r = await fetch(`https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=${APIKEY}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: custom, returnSecureToken: true }) + }) + const j = await r.json() + if (!j.idToken) throw new Error('no idToken: ' + JSON.stringify(j)) + return j.idToken +} + +async function tryGet(token, p, label) { + const r = await fetch(`${docBase}/${p}`, { headers: { Authorization: `Bearer ${token}` } }) + const ok = r.status === 200 + const verdict = ok ? '❌❌ LEAK (200)' : `✅ DENIED (${r.status})` + console.log(` ${label}: ${verdict} [${p}]`) + return ok +} +async function trySelfGrant(token, uid) { + const r = await fetch(`${docBase}/users/${uid}/entitlements/premium?updateMask.fieldPaths=active`, { + method: 'PATCH', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ fields: { active: { booleanValue: true } } }) + }) + console.log(` self-grant premium: ${r.status === 200 ? '❌❌ ALLOWED (200)' : `✅ DENIED (${r.status})`}`) + return r.status === 200 +} + +(async () => { + console.log('=== D3 non-member negative access (uid', FAKE_UID, ') ===') + const tok = await getIdToken() + let leaks = 0 + if (await tryGet(tok, `couples/${COUPLE}`, 'read couple doc')) leaks++ + if (await tryGet(tok, `couples/${COUPLE}/conversations/main/messages`, 'list messages')) leaks++ + if (await tryGet(tok, `couples/${COUPLE}/capsules`, 'list capsules')) leaks++ + if (await tryGet(tok, `couples/${COUPLE}/desire_sync`, 'list desire_sync')) leaks++ + if (await trySelfGrant(tok, FAKE_UID)) leaks++ + // cleanup the throwaway auth user + await admin.auth().deleteUser(FAKE_UID).catch(() => {}) + console.log(leaks === 0 ? '=== D3 PASS: all denied ===' : `=== D3 FAIL: ${leaks} LEAK(S) — P0 ===`) + process.exit(leaks === 0 ? 0 : 1) +})().catch(e => { console.error('FATAL', e.message); process.exit(2) })