147 lines
5.2 KiB
JavaScript
147 lines
5.2 KiB
JavaScript
import { getPool } from "../../db/index.js";
|
|
|
|
// Lazy import so this module does not pull in @jlinc/core.
|
|
const defaultVerify = async (input, userId) =>
|
|
(await import("../core/data/index.js")).data.audit.verify(input, userId);
|
|
|
|
// A target is ONE ROW: { eventUuid } covers all of an event's audit records,
|
|
// { auditId } covers exactly one. Audit rows share an eventUuid, so keying them
|
|
// by event reports one record's verdict on all its siblings.
|
|
const MAX_BATCH = 200;
|
|
|
|
// Joined through `event` on user_id: an audit id is a lookup key, never an
|
|
// authorization decision.
|
|
async function fetchAuditRecords(client, userId, { eventUuid, auditId }) {
|
|
const byAuditId = auditId != null;
|
|
const rows = (
|
|
await client.query(
|
|
`SELECT a.audit_id, a.version, a.event_id, a.hash_type, a.digest, a.created,
|
|
e.sender_id, e.recipient_id,
|
|
COALESCE(
|
|
JSON_AGG(
|
|
JSON_BUILD_OBJECT('version', s.version, 'id', s.id, 'signedon', s.signedon, 'type', s.type, 'jws', s.jws)
|
|
) FILTER (WHERE s.audit_id IS NOT NULL),
|
|
'[]'
|
|
) AS signatures
|
|
FROM audit a
|
|
JOIN event e ON e.event_id_uuid = a.event_id AND e.user_id = $1
|
|
LEFT JOIN audit_signature s ON s.audit_id = a.audit_id
|
|
WHERE ${byAuditId ? "a.audit_id = $2" : "a.event_id = $2"}
|
|
GROUP BY a.audit_id, a.version, a.event_id, a.hash_type, a.digest, a.created,
|
|
e.sender_id, e.recipient_id
|
|
ORDER BY a.audit_id
|
|
LIMIT $3`,
|
|
[userId, byAuditId ? auditId : eventUuid, byAuditId ? 1 : MAX_BATCH],
|
|
)
|
|
).rows;
|
|
|
|
const dids = new Set();
|
|
const records = rows.map((row) => {
|
|
if (row.sender_id) dids.add(row.sender_id);
|
|
if (row.recipient_id) dids.add(row.recipient_id);
|
|
return {
|
|
auditId: Number(row.audit_id),
|
|
audit: {
|
|
version: row.version,
|
|
hashType: row.hash_type,
|
|
digest: row.digest,
|
|
created: Number(row.created),
|
|
eventId: row.event_id,
|
|
},
|
|
signatures: (row.signatures || []).map((r) => ({
|
|
version: r.version,
|
|
id: r.id,
|
|
signedOn: Number(r.signedon),
|
|
type: r.type,
|
|
jws: r.jws,
|
|
})),
|
|
};
|
|
});
|
|
return { records, dids: [...dids] };
|
|
}
|
|
|
|
// Only the DIDs the batch actually references, and only did_doc — getEntity
|
|
// would return the control and recovery private keys, which verification never
|
|
// needs. Resolved once per batch and memoised across targets.
|
|
async function resolveDidDocs(client, userId, dids, cache) {
|
|
const missing = dids.filter((did) => !cache.has(did));
|
|
if (missing.length > 0) {
|
|
const rows = (
|
|
await client.query(
|
|
`SELECT did_id, did_doc FROM entity WHERE user_id = $1 AND did_id = ANY($2)`,
|
|
[userId, missing],
|
|
)
|
|
).rows;
|
|
for (const did of missing) cache.set(did, null);
|
|
for (const row of rows) cache.set(row.did_id, row.did_doc);
|
|
}
|
|
return dids.map((did) => cache.get(did)).filter(Boolean);
|
|
}
|
|
|
|
export function summarizeVerification(target, records, verifierResult) {
|
|
const { eventUuid = null, auditId = null } = target;
|
|
if (records.length === 0) {
|
|
return {
|
|
eventUuid,
|
|
auditId,
|
|
status: "no-audit",
|
|
verified: false,
|
|
signatureCount: 0,
|
|
auditCount: 0,
|
|
checks: null,
|
|
reason: "no audit record",
|
|
};
|
|
}
|
|
|
|
const valid = verifierResult?.data?.valid || [];
|
|
const invalid = verifierResult?.data?.invalid || [];
|
|
// Matched by audit id: the verifier splits input across two buckets, so
|
|
// position is meaningless once a target has several records.
|
|
const verdicts = records.map((r) => ({
|
|
verified: valid.some((e) => e.audit?.auditId === r.auditId),
|
|
checks: [...valid, ...invalid].find((e) => e.audit?.auditId === r.auditId)?.results || null,
|
|
}));
|
|
|
|
const failed = verdicts.find((v) => !v.verified);
|
|
return {
|
|
eventUuid,
|
|
auditId,
|
|
status: failed ? "invalid" : "verified",
|
|
verified: !failed,
|
|
signatureCount: records.reduce((n, r) => n + r.signatures.length, 0),
|
|
auditCount: records.length,
|
|
checks: (failed || verdicts[0]).checks,
|
|
};
|
|
}
|
|
|
|
async function verifyTarget(acquire, verify, userId, target, cache) {
|
|
const client = await acquire();
|
|
let records, didDocs;
|
|
try {
|
|
const fetched = await fetchAuditRecords(client, userId, target);
|
|
records = fetched.records;
|
|
didDocs = records.length === 0 ? [] : await resolveDidDocs(client, userId, fetched.dids, cache);
|
|
} finally {
|
|
await client.release();
|
|
}
|
|
if (records.length === 0) return summarizeVerification(target, records, null);
|
|
|
|
const result = await verify({ didDocs, audits: records }, userId);
|
|
return summarizeVerification(target, records, result);
|
|
}
|
|
|
|
export async function verifyMany(userId, { eventUuids = [], auditIds = [] } = {}, { acquire = getPool, verify = defaultVerify } = {}) {
|
|
const targets = [
|
|
...eventUuids.map((eventUuid) => ({ eventUuid, auditId: null })),
|
|
...auditIds.map((auditId) => ({ eventUuid: null, auditId })),
|
|
].slice(0, MAX_BATCH);
|
|
if (targets.length === 0) return [];
|
|
|
|
const cache = new Map();
|
|
const results = [];
|
|
for (const target of targets) {
|
|
results.push(await verifyTarget(acquire, verify, userId, target, cache));
|
|
}
|
|
return results;
|
|
}
|