App: QR-Uebergabe mit dem Beratungs-PC

- Brief -> "Am Computer zeigen": schickt den Brief verschluesselt auf den
  Bildschirm der Beratung (zweisprachig, wenn eine Uebersetzung da ist)
- Archiv -> "Vom Computer uebernehmen": holt das Ergebnis vom PC ins
  verschluesselte Archiv, mit Erinnerungen
- Gleiches Paeckchen-Format wie die Webseite (expo-crypto combined =
  IV||Chiffrat||Tag); Test prueft beide Richtungen gegen WebCrypto
- Link-Einfuegen als Ersatz, wenn keine Kamera da ist

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JUSNandClaude Opus 5.5 committed 2026-10-22 18:46:29 +02:00
1 parent 014737bd95
commit cf188af080
9 files changed
+509 -1

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
/// <reference types="node" />
/**
* Übergabe Handy ↔ PC: App und Webseite müssen die Päckchen gegenseitig lesen.
*
* Die Web-Seite wird hier mit WebCrypto nachgebaut — exakt wie in
* webseite/uebergabe.js. Für die App läuft die echte Web-Implementierung von
* expo-crypto (gleiche Kombi-Reihenfolge IV‖Chiffrat‖Tag wie nativ).
*/
jest.mock('expo-crypto/build/aes/ExpoCryptoAES', () => jest.requireActual('expo-crypto/build/aes/ExpoCryptoAES.web'));
import { webcrypto } from 'crypto';
import { holeAusUebergabe, oeffneUebergabe, sendeAnUebergabe } from '../../services/uebergabe';
import { briefZuWebPaket, leseUebergabeLink, webPaketZuBrief } from '../uebergabeFormat';
import { BriefEintrag } from '../../types';
const subtle = webcrypto.subtle;
const b64u = (b: Uint8Array) => Buffer.from(b).toString('base64url');
// --- Web-Seite (wie webseite/uebergabe.js) ---
async function webSchluessel(k: string) {
return subtle.importKey('raw', Buffer.from(k, 'base64url'), { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
async function webVerschluesseln(k: string, daten: unknown) {
const iv = webcrypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, await webSchluessel(k), new TextEncoder().encode(JSON.stringify(daten))));
return b64u(Buffer.concat([iv, ct]));
}
async function webEntschluesseln(k: string, text: string) {
const b = Buffer.from(text, 'base64url');
const klar = await subtle.decrypt({ name: 'AES-GCM', iv: b.subarray(0, 12) }, await webSchluessel(k), b.subarray(12));
return JSON.parse(new TextDecoder().decode(klar));
}
const ID = 'v1wRNjDlmRZeGSS_YRiRVA';
const KEY = b64u(webcrypto.getRandomValues(new Uint8Array(32)));
const link = (a: string) => `https://behoerdenklar.de/demo#a=${a}&s=${ID}&k=${KEY}&l=ar&d=1`;
const ANALYSE_DE = {
brieftyp: 'Aufforderung', absender: 'Ausländerbehörde', kernaussage: 'Unterlagen fehlen.',
erklaerung_einfach: 'Bitte bis 12.11. einreichen.', fachbegriffe: [{ begriff: 'Fiktionsbescheinigung', erklaerung: 'Papier' }],
frist: { datum: '2026-11-12', aktion: 'Unterlagen einreichen' }, termin: null, checkliste: ['Pass'],
kontakt: { telefon: '069 90009-314' }, anruf: [],
};
const ANALYSE_AR = { ...ANALYSE_DE, kernaussage: 'المستندات ناقصة.', checkliste: ['جواز السفر'] };
describe('leseUebergabeLink', () => {
it('liest Kamera- und Mitnehmen-Codes', () => {
expect(leseUebergabeLink(link('k'))).toMatchObject({ art: 'k', id: ID, schluessel: KEY, basis: 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev' });
expect(leseUebergabeLink(link('m'))?.art).toBe('m');
});
it('nimmt beim lokalen Test den lokalen Worker', () => {
expect(leseUebergabeLink(`http://localhost:4173/demo.html#a=m&s=${ID}&k=${KEY}`)?.basis).toBe('http://localhost:8799');
});
it('lehnt fremde Seiten und kaputte Codes ab', () => {
expect(leseUebergabeLink(`https://boese.example/demo#a=m&s=${ID}&k=${KEY}`)).toBeNull();
expect(leseUebergabeLink('https://behoerdenklar.de/demo#a=m&s=kurz&k=kurz')).toBeNull();
expect(leseUebergabeLink('Hallo')).toBeNull();
});
});
describe('Verschlüsselung App ↔ Webseite', () => {
const fetchOriginal = global.fetch;
afterEach(() => { global.fetch = fetchOriginal; });
it('App liest, was die Webseite verschlüsselt hat', async () => {
const chiffrat = await webVerschluesseln(KEY, { analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' });
global.fetch = jest.fn(async () => new Response(JSON.stringify({ faecher: { ergebnis: chiffrat, fremd: 'QUJD' } }))) as typeof fetch;
const u = await oeffneUebergabe(leseUebergabeLink(link('m'))!);
const faecher = await holeAusUebergabe(u);
expect(faecher.ergebnis).toEqual({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' });
expect(faecher.fremd).toBeUndefined(); // nicht entschlüsselbar → ignoriert
});
it('Webseite liest, was die App verschlüsselt hat', async () => {
let gesendet = '';
let adresse = '';
global.fetch = jest.fn(async (url: string, opt: RequestInit) => {
adresse = url;
gesendet = String(opt.body);
return new Response(null, { status: 204 });
}) as unknown as typeof fetch;
const u = await oeffneUebergabe(leseUebergabeLink(link('k'))!);
await sendeAnUebergabe(u, 'ergebnis', { analyse: ANALYSE_DE, sprache: 'de' });
expect(adresse).toBe(`https://behoerdenklar-proxy.behoerdenbrief.workers.dev/uebergabe/${ID}/ergebnis`);
expect(gesendet).toMatch(/^[A-Za-z0-9_-]+$/); // base64url, wie der Worker verlangt
expect(await webEntschluesseln(KEY, gesendet)).toEqual({ analyse: ANALYSE_DE, sprache: 'de' });
});
});
describe('Umwandeln Web ↔ App', () => {
it('Ergebnis vom PC wird ein Brief: Deutsch als Grundlage, Arabisch als Übersetzung', () => {
const b = webPaketZuBrief({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }, 'id1', new Date('2026-10-29T10:00:00Z'));
expect(b.analyse.kernaussage).toBe('Unterlagen fehlen.');
expect(b.analyse.frist).toEqual({ datum: '2026-11-12', aktion: 'Unterlagen einreichen' });
expect(b.analyse.antwort_noetig).toBe(false);
expect(b.uebersetzungen.ar.kernaussage).toBe('المستندات ناقصة.');
expect(b.erstelltAm).toBe('2026-10-29T10:00:00.000Z');
});
it('rein deutsches Ergebnis hat keine Übersetzung', () => {
const b = webPaketZuBrief({ analyse: ANALYSE_DE, sprache: 'de' }, 'id2', new Date());
expect(b.analyse.kernaussage).toBe('Unterlagen fehlen.');
expect(b.uebersetzungen).toEqual({});
});
it('Brief aus dem Archiv geht zweisprachig an den PC', () => {
const brief = webPaketZuBrief({ analyse: ANALYSE_AR, analyse_de: ANALYSE_DE, sprache: 'ar' }, 'id3', new Date());
const p = briefZuWebPaket(brief as BriefEintrag);
expect(p.sprache).toBe('ar');
expect(p.analyse.kernaussage).toBe('المستندات ناقصة.');
expect(p.analyse.frist).toEqual(ANALYSE_DE.frist);
expect(p.analyse_de?.kernaussage).toBe('Unterlagen fehlen.');
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Übergabe Handy ↔ PC — reine Hilfsfunktionen ohne native Module (testbar).
*
* Gleiches Protokoll wie die Webseite (webseite/uebergabe.js): Der QR-Code
* enthält hinter dem `#` eine Übergabe-ID (s), einen AES-256-Schlüssel (k)
* und die Art der Übergabe (a):
* a=k „Kamera": der PC wartet auf einen Brief → App schickt ihn hin
* a=m „Mitnehmen": der PC hat ein Ergebnis bereitgelegt → App holt es
* a=g Guthaben der Webseite (gilt nur im Browser, nicht in der App)
*/
import { BriefAnalyse, BriefEintrag, Fachbegriff, Uebersetzung } from '../types';
export type UebergabeArt = 'k' | 'm' | 'g';
export interface UebergabeLink {
art: UebergabeArt;
id: string;
schluessel: string; // base64url, 32 Byte
/** Worker, der die Päckchen hält (lokal beim Testen, sonst live). */
basis: string;
}
const LIVE_BASIS = 'https://behoerdenklar-proxy.behoerdenbrief.workers.dev';
const LOKAL_BASIS = 'http://localhost:8799';
const ERLAUBTE_SEITEN = /^https:\/\/([a-z0-9-]+\.)?behoerdenklar\.(de|pages\.dev)\//;
const LOKALE_SEITE = /^http:\/\/(localhost|127\.0\.0\.1):\d+\//;
/** Liest einen gescannten QR-Code. null = kein BehördenKlar-Code. */
export function leseUebergabeLink(text: string): UebergabeLink | null {
const t = (text || '').trim();
const lokal = LOKALE_SEITE.test(t);
if (!ERLAUBTE_SEITEN.test(t) && !lokal) return null;
const raute = t.indexOf('#');
if (raute < 0) return null;
const werte: Record<string, string> = {};
for (const teil of t.slice(raute + 1).split('&')) {
const i = teil.indexOf('=');
if (i > 0) werte[teil.slice(0, i)] = decodeURIComponent(teil.slice(i + 1));
}
const art = werte.a as UebergabeArt;
if (art === 'g') return { art, id: '', schluessel: '', basis: '' };
if ((art !== 'k' && art !== 'm') || !/^[A-Za-z0-9_-]{22}$/.test(werte.s ?? '') || !/^[A-Za-z0-9_-]{43}$/.test(werte.k ?? '')) {
return null;
}
return { art, id: werte.s, schluessel: werte.k, basis: lokal ? LOKAL_BASIS : LIVE_BASIS };
}
export function base64UrlZuBase64(s: string): string {
const b = s.replace(/-/g, '+').replace(/_/g, '/');
return b + '='.repeat((4 - (b.length % 4)) % 4);
}
export function base64ZuBase64Url(s: string): string {
return s.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** Was die Webseite als Ergebnis schickt bzw. erwartet. */
export interface WebPaket {
analyse: WebAnalyse;
analyse_de?: WebAnalyse;
sprache?: string;
}
/** Die Analyse der Webseite: wie BriefAnalyse, dafür mit Kontakt + Anruf-Hilfe. */
type WebAnalyse = Partial<BriefAnalyse> & {
kontakt?: unknown;
anruf?: unknown;
};
function textOder(s: unknown, ersatz = ''): string {
return typeof s === 'string' ? s : ersatz;
}
function alsBriefAnalyse(w: WebAnalyse): BriefAnalyse {
return {
brieftyp: textOder(w.brieftyp, 'Brief'),
absender: textOder(w.absender),
kernaussage: textOder(w.kernaussage),
erklaerung_einfach: textOder(w.erklaerung_einfach),
fachbegriffe: Array.isArray(w.fachbegriffe) ? (w.fachbegriffe as Fachbegriff[]) : [],
frist: w.frist ?? null,
termin: w.termin ?? null,
checkliste: Array.isArray(w.checkliste) ? w.checkliste.filter((x) => typeof x === 'string') : [],
// Die Web-Analyse kennt keine Antwort-Vorschläge — die App kann sie nicht erfinden
antwort_noetig: false,
antwort_optionen: [],
};
}
/** Ergebnis vom PC → Brief fürs Archiv. Deutsch ist die Grundlage, die
* andere Sprache landet im Übersetzungs-Cache (wie bei App-Übersetzungen). */
export function webPaketZuBrief(p: WebPaket, id: string, jetzt: Date): BriefEintrag {
const sprache = p.sprache && p.sprache !== 'de' ? p.sprache : null;
const deutsch = p.analyse_de ?? (sprache ? null : p.analyse);
const basis = alsBriefAnalyse(deutsch ?? p.analyse);
const uebersetzungen: Record<string, Uebersetzung> = {};
if (sprache) {
const f = alsBriefAnalyse(p.analyse);
uebersetzungen[sprache] = {
kernaussage: f.kernaussage,
erklaerung_einfach: f.erklaerung_einfach,
checkliste: f.checkliste,
fachbegriffe: f.fachbegriffe,
};
}
return { id, erstelltAm: jetzt.toISOString(), analyse: basis, uebersetzungen };
}
/** Brief aus dem Archiv → Ergebnis für den PC. Gibt es eine Übersetzung,
* bekommt der PC beide Sprachen (Deutsch zuerst für die Beratung). */
export function briefZuWebPaket(b: BriefEintrag): WebPaket {
const deutsch: WebAnalyse = { ...b.analyse };
const [sprache] = Object.keys(b.uebersetzungen ?? {});
if (!sprache) return { analyse: deutsch, sprache: 'de' };
return {
analyse: { ...b.analyse, ...b.uebersetzungen[sprache] },
analyse_de: deutsch,
sprache,
};
}