2026-08-28 11:12:41 +02:00
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
// Beleid: wie mag er op deze relay schrijven, en wat gebeurt er met een
|
|
|
|
|
// onbekende eigenaar.
|
|
|
|
|
//
|
|
|
|
|
// Alles hier is een pure functie: geen bestanden, geen netwerk, geen klok. De
|
|
|
|
|
// aanroeper geeft de huidige staat en het huidige tijdstip mee en krijgt een
|
|
|
|
|
// nieuwe staat terug. Dat is met opzet, want dit is de enige plek waar staat wie
|
|
|
|
|
// er binnenkomt, en zulke logica hoort te testen te zijn zonder relay en zonder
|
|
|
|
|
// Docker.
|
|
|
|
|
//
|
|
|
|
|
// De relay van Evolu roept `isOwnerAllowed(ownerId)` aan. Wij beantwoorden die
|
|
|
|
|
// vraag hier; `src/index.js` doet niets anders dan lezen, doorgeven en opslaan.
|
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
|
|
|
|
|
|
export const STATE_VERSION = 1;
|
|
|
|
|
|
|
|
|
|
// Hoeveel geweigerde eigenaars we onthouden om op de pagina te tonen. Er zit een
|
|
|
|
|
// grens op omdat een onbekende die blijft proberen anders het bestand vol
|
|
|
|
|
// schrijft: elke poging is een eigenaar-id dat hij zelf verzint.
|
|
|
|
|
export const MAX_REJECTED = 20;
|
|
|
|
|
|
|
|
|
|
// Een `OwnerId` is een publieke identificatie en geen geheim, maar hij komt van
|
|
|
|
|
// buiten en belandt in een bestand. Daarom een bovengrens en een typecontrole, en
|
|
|
|
|
// verder geen aannames over de vorm.
|
|
|
|
|
export const MAX_OWNER_ID_LENGTH = 256;
|
|
|
|
|
|
|
|
|
|
export const createEmptyState = () => ({
|
|
|
|
|
version: STATE_VERSION,
|
|
|
|
|
// Leerstand. Aan betekent: de eerstvolgende onbekende eigenaar wordt
|
|
|
|
|
// toegelaten. Dit staat aan bij een verse installatie, want anders kan de
|
|
|
|
|
// eigenaar zichzelf nooit aanmelden: Trezor Suite toont je `OwnerId` nergens.
|
|
|
|
|
learning: true,
|
|
|
|
|
owners: [],
|
|
|
|
|
rejected: [],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const isUsableOwnerId = (value) =>
|
|
|
|
|
typeof value === 'string' && value.length > 0 && value.length <= MAX_OWNER_ID_LENGTH;
|
|
|
|
|
|
|
|
|
|
const findOwner = (state, ownerId) => state.owners.find((owner) => owner.id === ownerId);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Leest een staat die van schijf komt. Geeft `null` terug als het niet klopt.
|
|
|
|
|
*
|
|
|
|
|
* Bewust streng: een half begrepen bestand is gevaarlijker dan geen bestand,
|
|
|
|
|
* want dan denkt de app dat er een allowlist is terwijl die leeg is. De
|
|
|
|
|
* aanroeper beslist wat er bij `null` gebeurt, en die keuze staat in `store.js`.
|
|
|
|
|
*/
|
|
|
|
|
export const normalizeState = (raw) => {
|
|
|
|
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
|
|
|
if (raw.version !== STATE_VERSION) return null;
|
|
|
|
|
if (typeof raw.learning !== 'boolean') return null;
|
|
|
|
|
if (!Array.isArray(raw.owners) || !Array.isArray(raw.rejected)) return null;
|
|
|
|
|
|
|
|
|
|
const owners = [];
|
|
|
|
|
for (const owner of raw.owners) {
|
|
|
|
|
if (owner === null || typeof owner !== 'object') return null;
|
|
|
|
|
if (!isUsableOwnerId(owner.id)) return null;
|
|
|
|
|
if (typeof owner.allowed !== 'boolean') return null;
|
|
|
|
|
owners.push({
|
|
|
|
|
id: owner.id,
|
|
|
|
|
allowed: owner.allowed,
|
|
|
|
|
firstSeen: typeof owner.firstSeen === 'string' ? owner.firstSeen : null,
|
|
|
|
|
lastSeen: typeof owner.lastSeen === 'string' ? owner.lastSeen : null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const rejected = [];
|
|
|
|
|
for (const entry of raw.rejected) {
|
|
|
|
|
if (entry === null || typeof entry !== 'object') return null;
|
|
|
|
|
if (!isUsableOwnerId(entry.id)) return null;
|
|
|
|
|
rejected.push({
|
|
|
|
|
id: entry.id,
|
|
|
|
|
firstSeen: typeof entry.firstSeen === 'string' ? entry.firstSeen : null,
|
|
|
|
|
lastSeen: typeof entry.lastSeen === 'string' ? entry.lastSeen : null,
|
|
|
|
|
attempts: Number.isInteger(entry.attempts) && entry.attempts > 0 ? entry.attempts : 1,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { version: STATE_VERSION, learning: raw.learning, owners, rejected };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const rememberRejected = (rejected, ownerId, now) => {
|
|
|
|
|
const existing = rejected.find((entry) => entry.id === ownerId);
|
|
|
|
|
if (existing) {
|
|
|
|
|
return rejected.map((entry) =>
|
|
|
|
|
entry.id === ownerId ? { ...entry, lastSeen: now, attempts: entry.attempts + 1 } : entry,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
// Nieuwste vooraan, en afkappen op MAX_REJECTED. De oudste valt eraf; dat is
|
|
|
|
|
// hier de juiste kant om te verliezen, want een aanvaller die blijft proberen
|
|
|
|
|
// duwt dan zijn eigen eerdere pogingen weg en niet de allowlist.
|
|
|
|
|
return [{ id: ownerId, firstSeen: now, lastSeen: now, attempts: 1 }, ...rejected].slice(
|
|
|
|
|
0,
|
|
|
|
|
MAX_REJECTED,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Beslist of deze eigenaar erin mag, en geeft de staat terug zoals hij daarna is.
|
|
|
|
|
*
|
|
|
|
|
* @returns {{allowed: boolean, state: object, reason: string, changed: boolean}}
|
|
|
|
|
*/
|
|
|
|
|
export const decideOwner = (state, ownerId, now) => {
|
|
|
|
|
if (!isUsableOwnerId(ownerId)) {
|
|
|
|
|
// Geen bruikbaar id: weigeren en niets onthouden. Onthouden zou hier juist
|
|
|
|
|
// de weg openzetten om het bestand vol te schrijven met rommel.
|
|
|
|
|
return { allowed: false, state, reason: 'invalid-owner-id', changed: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const known = findOwner(state, ownerId);
|
|
|
|
|
|
|
|
|
|
if (known) {
|
|
|
|
|
const seen = { ...known, lastSeen: now };
|
|
|
|
|
const owners = state.owners.map((owner) => (owner.id === ownerId ? seen : owner));
|
|
|
|
|
return {
|
|
|
|
|
allowed: known.allowed,
|
|
|
|
|
state: { ...state, owners },
|
|
|
|
|
reason: known.allowed ? 'known-allowed' : 'known-blocked',
|
|
|
|
|
changed: known.lastSeen !== now,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.learning) {
|
|
|
|
|
const owners = [...state.owners, { id: ownerId, allowed: true, firstSeen: now, lastSeen: now }];
|
|
|
|
|
return { allowed: true, state: { ...state, owners }, reason: 'learned', changed: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
allowed: false,
|
|
|
|
|
state: { ...state, rejected: rememberRejected(state.rejected, ownerId, now) },
|
|
|
|
|
reason: 'not-learning',
|
|
|
|
|
changed: true,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Voert een opdracht van de statuspagina uit.
|
|
|
|
|
*
|
|
|
|
|
* De pagina schrijft een opdrachtbestand en dit proces past hem toe; er is geen
|
|
|
|
|
* Docker-socket en geen tweede container die in dezelfde staat schrijft. Dat is
|
|
|
|
|
* dezelfde afspraak als bij Electrum Gate.
|
|
|
|
|
*
|
|
|
|
|
* @returns {{state: object, changed: boolean, error: string|null}}
|
|
|
|
|
*/
|
|
|
|
|
export const applyCommand = (state, command, now) => {
|
|
|
|
|
const unchanged = (error) => ({ state, changed: false, error });
|
|
|
|
|
|
|
|
|
|
if (command === null || typeof command !== 'object') return unchanged('malformed-command');
|
|
|
|
|
|
|
|
|
|
switch (command.action) {
|
|
|
|
|
case 'set-learning': {
|
|
|
|
|
if (typeof command.value !== 'boolean') return unchanged('malformed-command');
|
|
|
|
|
if (state.learning === command.value) return { state, changed: false, error: null };
|
|
|
|
|
return { state: { ...state, learning: command.value }, changed: true, error: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'block': {
|
|
|
|
|
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
|
|
|
|
if (!findOwner(state, command.ownerId)) return unchanged('unknown-owner');
|
|
|
|
|
const owners = state.owners.map((owner) =>
|
|
|
|
|
owner.id === command.ownerId ? { ...owner, allowed: false, lastSeen: owner.lastSeen } : owner,
|
|
|
|
|
);
|
|
|
|
|
return { state: { ...state, owners }, changed: true, error: null };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'allow': {
|
|
|
|
|
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
|
|
|
|
const known = findOwner(state, command.ownerId);
|
|
|
|
|
// Ook een eigenaar die alleen als geweigerde poging bekend is, mag hiermee
|
|
|
|
|
// alsnog toegelaten worden. Dat is de knop naast zo'n regel op de pagina.
|
2026-08-28 12:08:19 +02:00
|
|
|
//
|
|
|
|
|
// De geschiedenis van die pogingen gaat mee. Dat leek een detail tot het
|
|
|
|
|
// op 28-08-2026 misging: de pagina meldde daarna "first seen" op het moment
|
|
|
|
|
// van de klik, terwijl die eigenaar al twintig minuten aan de deur stond.
|
|
|
|
|
// Juist bij een eigenaar die je alsnog toelaat wil je kunnen terugzien
|
|
|
|
|
// sinds wanneer hij er is.
|
|
|
|
|
const attempted = state.rejected.find((entry) => entry.id === command.ownerId);
|
2026-08-28 11:12:41 +02:00
|
|
|
const owners = known
|
|
|
|
|
? state.owners.map((owner) =>
|
|
|
|
|
owner.id === command.ownerId ? { ...owner, allowed: true } : owner,
|
|
|
|
|
)
|
2026-08-28 12:08:19 +02:00
|
|
|
: [
|
|
|
|
|
...state.owners,
|
|
|
|
|
{
|
|
|
|
|
id: command.ownerId,
|
|
|
|
|
allowed: true,
|
|
|
|
|
firstSeen: attempted && attempted.firstSeen ? attempted.firstSeen : now,
|
|
|
|
|
lastSeen: attempted ? attempted.lastSeen : null,
|
|
|
|
|
},
|
|
|
|
|
];
|
2026-08-28 11:12:41 +02:00
|
|
|
return {
|
|
|
|
|
state: {
|
|
|
|
|
...state,
|
|
|
|
|
owners,
|
|
|
|
|
rejected: state.rejected.filter((entry) => entry.id !== command.ownerId),
|
|
|
|
|
},
|
|
|
|
|
changed: true,
|
|
|
|
|
error: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case 'forget': {
|
|
|
|
|
if (!isUsableOwnerId(command.ownerId)) return unchanged('malformed-command');
|
|
|
|
|
const inOwners = Boolean(findOwner(state, command.ownerId));
|
|
|
|
|
const inRejected = state.rejected.some((entry) => entry.id === command.ownerId);
|
|
|
|
|
if (!inOwners && !inRejected) return unchanged('unknown-owner');
|
|
|
|
|
return {
|
|
|
|
|
state: {
|
|
|
|
|
...state,
|
|
|
|
|
owners: state.owners.filter((owner) => owner.id !== command.ownerId),
|
|
|
|
|
rejected: state.rejected.filter((entry) => entry.id !== command.ownerId),
|
|
|
|
|
},
|
|
|
|
|
changed: true,
|
|
|
|
|
error: null,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
return unchanged('unknown-action');
|
|
|
|
|
}
|
|
|
|
|
};
|