security(scratchpad): add R17 Firestore at-rest and negative-access probe scripts

This commit is contained in:
null 2026-06-28 15:45:54 -05:00
parent 33ea862934
commit 38ff166598
4 changed files with 187 additions and 0 deletions

59
scratchpad/d1_atrest.js Normal file
View File

@ -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) })

42
scratchpad/d1_probe2.js Normal file
View File

@ -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) })

33
scratchpad/d1_probe3.js Normal file
View File

@ -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) })

53
scratchpad/d3_negative.js vendored Normal file
View File

@ -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) })