SEC.SI CARE

Technisches Lastenheft · Phase 1

v1.0 · 2026-03

Dating-Notruf-App · Offline-Keyword-Erkennung · GPS-Handshake

Keyword-Erkennung
Akku-optimiert
GPS-Handshake
AES-256-GCM

Anforderungsübersicht · Phase 1

F-01MUST

Offline-Keyword-Erkennung Android

TFLite + MFCC, ≥85% Konfidenz, kein Internet nötig

F-02MUST

Offline-Keyword-Erkennung iOS

CoreML + SoundAnalysis, ANE-beschleunigt

F-03MUST

Background-Service Persistence

START_STICKY, Duty-Cycle Akku-Management

F-04MUST

GPS-Handshake verschlüsselt

ECDH P-384 + AES-256-GCM + HMAC-SHA256

F-05SHOULD

Adaptive Akku-Modi

Normal / Eco / Ultra-Eco nach Ladezustand

F-06SHOULD

OTA-Modell-Updates

Keyword-Modell ohne App-Store-Release aktualisierbar

NF-01MUST

Keyword-Latenz ≤ 500ms

Von Trigger-Wort bis SOS-Alarm-Auslösung

NF-02MUST

GPS-Update Latenz ≤ 200ms

Verschlüsseln + Senden + Server-ACK

NF-03MUST

Akku-Verbrauch ≤ 25mW (Eco)

Monitoring ohne merklichen Akku-Drain

85%

Min. Konfidenz

40

MFCC-Koeffizienten

50ms

Hop-Size

Android: TFLite Interpreter mit NNAPI-Delegate und XNNPACK — 3–5x schneller als CPU-only.

iOS: CoreML mit ANE (Apple Neural Engine) — minimaler CPU-Einsatz, maximale Effizienz.

Keywords: "hilfe", "notruf", "sos", "hilf mir" — mehrsprachig erweiterbar über Modell-Update.

kotlin · tflite-keyword-engine
// ═══════════════════════════════════════════════════════════════
// ANDROID · Offline-Keyword-Erkennung · TFLite + MFCC Pipeline
// ═══════════════════════════════════════════════════════════════

// ── Klasse 1: KeywordDetectionEngine ────────────────────────────
// Verantwortlich für On-Device Inferenz via TensorFlow Lite

class KeywordDetectionEngine(private val context: Context) {

    // ── Konstanten ────────────────────────────────────────────
    companion object {
        const val SAMPLE_RATE_HZ     = 16000  // 16kHz Mono
        const val WINDOW_SIZE_MS     = 1000   // 1s gleitendes Fenster
        const val HOP_SIZE_MS        = 50     // 50ms Overlap (Effizienz)
        const val NUM_MFCC_COEFF     = 40     // MFCC-Koeffizientenanzahl
        const val DETECTION_THRESHOLD = 0.85f // Min. Konfidenz für Trigger
        const val MODEL_FILE         = "secsi_keyword_v3.tflite"
        val TRIGGER_KEYWORDS         = listOf("hilfe", "notruf", "sos", "hilf mir")
    }

    // ── State ────────────────────────────────────────────────
    private var interpreter:    Interpreter? = null
    private var isRunning:      Boolean      = false
    private var detectionScope: CoroutineScope = CoroutineScope(Dispatchers.Default)

    // ── Eingabe/Ausgabe-Tensoren ─────────────────────────────
    private val inputBuffer:  Array<Array<FloatArray>> = Array(1) {
        Array(40) { FloatArray(32) }  // [batch, mfcc_coeff, time_frames]
    }
    private val outputBuffer: Array<FloatArray> = Array(1) {
        FloatArray(TRIGGER_KEYWORDS.size + 1)  // +1 für "background"
    }

    // ── Lifecycle ────────────────────────────────────────────
    fun initialize() {
        val modelFile = loadModelFromAssets(MODEL_FILE)
        val options   = Interpreter.Options().apply {
            numThreads     = 2           // Max. 2 Threads = Akku-schonend
            useNNAPI       = true        // Android Neural Networks API
            useXNNPACK     = true        // Optimierter Math-Kernel
        }
        interpreter = Interpreter(modelFile, options)
    }

    fun startDetection(onKeywordDetected: (keyword: String, confidence: Float) -> Unit) {
        isRunning = true
        detectionScope.launch {
            while (isRunning) {
                // Nicht-blockierendes Warten auf nächsten Audio-Frame
                val audioFrame = AudioCaptureService.getNextFrame()
                val mfccFeatures = MFCCExtractor.extract(audioFrame)
                val result = runInference(mfccFeatures)

                if (result.confidence >= DETECTION_THRESHOLD) {
                    withContext(Dispatchers.Main) {
                        onKeywordDetected(result.keyword, result.confidence)
                    }
                }
            }
        }
    }

    fun stopDetection() {
        isRunning = false
        detectionScope.cancel()
        interpreter?.close()
    }

    private fun runInference(features: Array<FloatArray>): DetectionResult {
        copyFeaturesToBuffer(features)
        interpreter!!.run(inputBuffer, outputBuffer)
        val scores = outputBuffer[0]
        val maxIdx = scores.indices.maxByOrNull { scores[it] } ?: 0
        return DetectionResult(
            keyword    = if (maxIdx < TRIGGER_KEYWORDS.size) TRIGGER_KEYWORDS[maxIdx] else "background",
            confidence = scores[maxIdx],
            isKeyword  = maxIdx < TRIGGER_KEYWORDS.size && scores[maxIdx] >= DETECTION_THRESHOLD
        )
    }

    private fun loadModelFromAssets(filename: String): MappedByteBuffer {
        val fd = context.assets.openFd(filename)
        return FileInputStream(fd.fileDescriptor).channel
            .map(FileChannel.MapMode.READ_ONLY, fd.startOffset, fd.declaredLength)
    }
}

// ── Datenklassen ─────────────────────────────────────────────────
data class DetectionResult(
    val keyword:    String,
    val confidence: Float,
    val isKeyword:  Boolean
)

data class MFCCFeatures(
    val coefficients: Array<FloatArray>,  // [num_coeff, time_frames]
    val timestamp:    Long,
    val energyLevel:  Float               // RMS-Energie → Stille-Erkennung
)

// ── Klasse 2: MFCCExtractor ───────────────────────────────────────
// On-Device Feature-Extraktion ohne externe Libraries

class MFCCExtractor {
    companion object {
        fun extract(audioFrame: ShortArray): Array<FloatArray> {
            val floatSamples = audioFrame.map { it / 32768.0f }.toFloatArray()
            val windowed     = applyHammingWindow(floatSamples)
            val fftResult    = FFTProcessor.compute(windowed)
            val melFilters   = MelFilterbank.apply(fftResult, numFilters = 40)
            val logMel       = melFilters.map { ln(it.coerceAtLeast(1e-10f)) }.toFloatArray()
            return DCTProcessor.compute(logMel, numCoefficients = 40)
        }
    }
}

BaseSecSIService

Abstract base — Foreground-Service-Boilerplate, unsichtbare Notification

SafetyMonitoringService

Haupt-Service — orchestriert alle Sub-Jobs via Coroutines + SupervisorJob

BatteryAwareAudioCapture

Duty-Cycle-Steuerung — 3 Modi je nach Akku-Stand

kotlin + swift · background-service-classes
// ═══════════════════════════════════════════════════════════════
// BACKGROUND SERVICE · Klassen-Hierarchie · Android + iOS
// Ziel: Mikrofon-Monitoring mit minimalem Akku-Verbrauch
// ═══════════════════════════════════════════════════════════════

// ─────────────────────────────────────────────────────────────────
// ANDROID: Klassen-Hierarchie
// ─────────────────────────────────────────────────────────────────

abstract class BaseSecSIService : Service() {
    abstract val serviceId:   Int
    abstract val channelId:   String
    abstract fun onSessionActive(): Boolean

    protected fun startAsForeground(label: String, icon: Int) {
        createNotificationChannel()
        startForeground(serviceId, buildMinimalNotification(label, icon))
    }

    private fun buildMinimalNotification(label: String, icon: Int): Notification =
        NotificationCompat.Builder(this, channelId)
            .setSmallIcon(icon)
            .setContentTitle("")         // Leer für Unauffälligkeit
            .setOngoing(true)
            .setPriority(NotificationCompat.PRIORITY_MIN)
            .setShowWhen(false)
            .setVisibility(NotificationCompat.VISIBILITY_SECRET)  // Nicht auf Lockscreen
            .build()
}

// ── Haupt-Service: SafetyMonitoringService ───────────────────────
class SafetyMonitoringService : BaseSecSIService() {

    override val serviceId = 1337
    override val channelId = "secsi_monitoring"
    override fun onSessionActive() = sessionRepository.hasActiveSession()

    // Sub-Services als Coroutine-Jobs
    private val serviceJobs = mutableMapOf<String, Job>()
    private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

    // Komponenten (Dependency Injection via Hilt)
    @Inject lateinit var keywordEngine:    KeywordDetectionEngine
    @Inject lateinit var audioCapturer:    BatteryAwareAudioCapture
    @Inject lateinit var locationTracker:  AdaptiveLocationTracker
    @Inject lateinit var alarmDispatcher:  AlarmDispatcher
    @Inject lateinit var sessionRepo:      SessionRepository

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        startAsForeground("", R.drawable.ic_secsi_minimal)

        when (intent?.action) {
            ACTION_START_SESSION  -> startAllMonitors()
            ACTION_STOP_SESSION   -> stopAllMonitors()
            ACTION_KEYWORD_ONLY   -> startKeywordOnly()   // Leichtgewichtig
        }
        return START_STICKY
    }

    private fun startAllMonitors() {
        serviceJobs["keyword"]  = serviceScope.launch { runKeywordDetection() }
        serviceJobs["location"] = serviceScope.launch { runLocationTracking() }
        serviceJobs["health"]   = serviceScope.launch { runHealthChecks() }
    }

    private suspend fun runKeywordDetection() {
        keywordEngine.initialize()
        audioCapturer.startCapture { frame ->
            keywordEngine.processFrame(frame)
                ?.let { alarmDispatcher.dispatchKeywordAlert(it) }
        }
    }

    private fun stopAllMonitors() {
        serviceJobs.values.forEach { it.cancel() }
        keywordEngine.stopDetection()
        audioCapturer.stop()
        locationTracker.stop()
        stopSelf()
    }
}

// ── BatteryAwareAudioCapture ──────────────────────────────────────
// Passt Audio-Qualität dynamisch an Akku-Stand an

class BatteryAwareAudioCapture @Inject constructor(
    private val context: Context,
    private val batteryMonitor: BatteryMonitor
) {
    companion object {
        // Volle Qualität (Akku > 30%)
        const val FULL_SAMPLE_RATE   = 16000
        const val FULL_BUFFER_SIZE   = 1600  // 100ms @ 16kHz

        // Eco-Modus (Akku ≤ 30%)
        const val ECO_SAMPLE_RATE    = 8000
        const val ECO_BUFFER_SIZE    = 800   // 100ms @ 8kHz
        const val ECO_DUTY_CYCLE_MS  = 500   // 500ms hören, 500ms pausieren

        // Ultra-Eco (Akku ≤ 15%)
        const val UECO_SAMPLE_RATE   = 8000
        const val UECO_DUTY_CYCLE_MS = 200   // 200ms hören, 800ms pausieren
    }

    private var audioRecord:     AudioRecord? = null
    private var isCapturing:     Boolean      = false
    private val captureScope     = CoroutineScope(Dispatchers.IO)

    fun startCapture(onFrame: (ShortArray) -> Unit) {
        isCapturing = true
        captureScope.launch {
            while (isCapturing) {
                val batteryLevel = batteryMonitor.getCurrentLevel()
                val config       = selectConfig(batteryLevel)
                captureFrame(config, onFrame)

                // Duty-Cycle-Pause (Akku-Schonung)
                if (config.hasDutyCycle) delay(config.pauseMs)
            }
        }
    }

    private fun selectConfig(batteryLevel: Int): CaptureConfig = when {
        batteryLevel > 30 -> CaptureConfig.FULL
        batteryLevel > 15 -> CaptureConfig.ECO
        else              -> CaptureConfig.ULTRA_ECO
    }

    private suspend fun captureFrame(config: CaptureConfig, onFrame: (ShortArray) -> Unit) {
        initAudioRecord(config)
        val buffer = ShortArray(config.bufferSize)
        val read   = audioRecord!!.read(buffer, 0, config.bufferSize)
        if (read > 0) onFrame(buffer.copyOf(read))
        if (!config.continuous) releaseAudioRecord()  // Eco: AudioRecord freigeben
    }
}

enum class CaptureConfig(
    val sampleRate:  Int,
    val bufferSize:  Int,
    val continuous:  Boolean,
    val hasDutyCycle:Boolean,
    val pauseMs:     Long
) {
    FULL      (16000, 1600, true,  false, 0),
    ECO       (8000,  800,  false, true,  500),
    ULTRA_ECO (8000,  800,  false, true,  800)
}

// ── AdaptiveLocationTracker ───────────────────────────────────────
class AdaptiveLocationTracker @Inject constructor(
    private val fusedLocationClient: FusedLocationProviderClient
) {
    // Intervall dynamisch anpassen: Gefahr = häufiger, Ruhephase = seltener
    private val dangerRequest = LocationRequest.Builder(Priority.HIGH_ACCURACY, 5_000).build()
    private val idleRequest   = LocationRequest.Builder(Priority.BALANCED_POWER, 30_000).build()

    fun setEscalationLevel(level: EscalationLevel) {
        val request = if (level == EscalationLevel.INTERVENTION) dangerRequest else idleRequest
        fusedLocationClient.requestLocationUpdates(request, locationCallback, Looper.getMainLooper())
    }
}

// ─────────────────────────────────────────────────────────────────
// iOS: Klassen-Hierarchie
// ─────────────────────────────────────────────────────────────────

// ── SafetyMonitoringCoordinator (iOS-Äquivalent zum Android-Service)

class SafetyMonitoringCoordinator {

    static let shared = SafetyMonitoringCoordinator()

    // Sub-Koordinatoren
    let keywordEngine      = KeywordDetectionEngine()
    let audioCapture       = BatteryAwareAudioCapture()
    let locationTracker    = AdaptiveLocationTracker()
    let alarmDispatcher    = AlarmDispatcher()
    var backgroundTask:    UIBackgroundTaskIdentifier = .invalid

    func startSession(type: SessionType) throws {
        // Background-Task registrieren (bis zu 30s bei App-Hintergrundwechsel)
        backgroundTask = UIApplication.shared.beginBackgroundTask {
            self.extendBackgroundExecution()
        }
        try keywordEngine.startDetection { [weak self] keyword, confidence in
            self?.alarmDispatcher.dispatch(keyword: keyword, confidence: confidence)
        }
        locationTracker.startAdaptiveTracking()
    }

    // BGProcessingTask für längere Background-Arbeit (iOS 13+)
    func scheduleBackgroundRefresh() {
        let request        = BGProcessingTaskRequest(identifier: "de.sec-si.monitoring")
        request.requiresNetworkConnectivity  = false
        request.requiresExternalPower        = false
        request.earliestBeginDate            = Date(timeIntervalSinceNow: 60)
        try? BGTaskScheduler.shared.submit(request)
    }
}

Normal (Akku > 30%)

55–92 mW

~16h Restlaufzeit

Eco (Akku ≤ 30%)

15–25 mW

~10h Restlaufzeit

Ultra-Eco (Akku ≤ 15%)

6–12 mW

~10h Restlaufzeit

spec · akku-budget
// ═══════════════════════════════════════════════════════════════
// AKKU-BUDGET · Spezifikation pro Monitoring-Modus
// Messungen auf: Pixel 7 (Android) / iPhone 15 (iOS)
// ═══════════════════════════════════════════════════════════════

AKKU_BUDGET_SPEZIFIKATION:

  // ── NORMAL-MODUS (Akku > 30%) ────────────────────────────────
  Keyword-Engine (TFLite NNAPI):      15–25 mW
  AudioRecord 16kHz Mono:              8–12 mW
  GPS FusedLocation (5s Intervall):   10–20 mW
  WebSocket Heartbeat (5s):            2–5  mW
  Hintergrund-App-Overhead:           20–30 mW
  ─────────────────────────────────────────────
  GESAMT NORMAL:                      55–92 mW
  Laufzeit (3000mAh Akku @ 55mW):   ~ 16 Stunden

  // ── ECO-MODUS (Akku ≤ 30%) ───────────────────────────────────
  Keyword-Engine (Duty-Cycle 50%):     8–12 mW
  AudioRecord 8kHz Duty-Cycle:         3–5  mW
  GPS (30s Intervall, BALANCED):       3–6  mW
  WebSocket Heartbeat (15s):           1–2  mW
  ─────────────────────────────────────────────
  GESAMT ECO:                         15–25 mW
  Laufzeit (restliche 30% = 900mAh): ~ 10 Stunden

  // ── ULTRA-ECO-MODUS (Akku ≤ 15%) ────────────────────────────
  Keyword-Engine (Duty-Cycle 20%):     4–6  mW
  AudioRecord 8kHz Duty-Cycle 20%:     1–2  mW
  GPS (60s Intervall, LOW_POWER):      1–3  mW
  WebSocket Heartbeat (30s):           0–1  mW
  ─────────────────────────────────────────────
  GESAMT ULTRA-ECO:                    6–12 mW
  Laufzeit (restliche 15% = 450mAh): ~ 10 Stunden

  // ── AKKU-OPTIMIERUNGEN ──────────────────────────────────────
  1. NNAPI / ANE nutzen (3–5x effizienter als CPU-only TFLite)
  2. Duty-Cycle AudioRecord: Release AudioRecord in Pause → GC
  3. GPS-Provider: FusedLocation (nicht raw GPS) → batterieschonend
  4. Batch-GPS-Uploads: Nicht jede GPS-Position sofort senden
  5. WakeLock: PARTIAL_WAKE_LOCK statt FULL_WAKE_LOCK
  6. JobScheduler für nicht-zeitkritische Tasks (GPS-Batch-Upload)
1

ECDH P-384 Key-Exchange

Ephemeral Keys → Perfect Forward Secrecy

2

HKDF Key-Derivation

Shared Secret → AES-256-GCM Session Key

3

AES-256-GCM Verschlüsselung

Pro GPS-Paket: neuer IV + Auth-Tag

4

HMAC-SHA256 Signatur

Transport-Integrität über gesamtes Paket

5

Server-Verifikation

HMAC + Auth-Tag + Seq-Nr. geprüft

pseudocode · encrypted-gps-handshake-schema
// ═══════════════════════════════════════════════════════════════
// GPS-HANDSHAKE SCHEMA · Ende-zu-Ende verschlüsselt
// Protokoll: ECDH P-384 + AES-256-GCM + HMAC-SHA256
// ═══════════════════════════════════════════════════════════════

// ─────────────────────────────────────────────────────────────────
// SCHRITT 1: Session-Key-Derivation (einmalig beim Session-Start)
// ─────────────────────────────────────────────────────────────────

GPS_SESSION_KEY_EXCHANGE:

  CLIENT → SERVER:
  POST /api/v2/session/gps-key-init
  Content-Type: application/json
  Authorization: Bearer {access_token}
  {
    "session_id":       "ses_abc123",
    "client_ecdh_pub":  "BASE64(P-384 Public Key)",    // Ephemeral!
    "device_id_hash":   "SHA256(device_fingerprint)",
    "timestamp":        "2026-03-11T22:00:00.000Z",
    "nonce":            "BASE64(32 random bytes)"
  }

  SERVER → CLIENT:
  HTTP 200
  {
    "session_gps_key_id":   "gpskey_xyz789",
    "server_ecdh_pub":      "BASE64(P-384 Public Key)",
    "server_nonce":         "BASE64(32 random bytes)",
    "key_valid_until":      "2026-03-11T23:00:00.000Z",  // 1h Gültigkeit
    "algorithm":            "ECDH-P384+HKDF+AES-256-GCM"
  }

  // BEIDE SEITEN leiten jetzt denselben GPS_SESSION_KEY ab:
  shared_secret  = ECDH(client_priv_key, server_pub_key)
  GPS_SESSION_KEY = HKDF(
    inputKeyMaterial: shared_secret,
    salt:    SHA256(client_nonce + server_nonce),
    info:    "secsi-gps-v1-" + session_id,
    length:  32    // 256 Bit AES-Key
  )
  // Server hat den Key, Client hat den Key — NIEMAND SONST

// ─────────────────────────────────────────────────────────────────
// SCHRITT 2: GPS-Update Payload (jede Übertragung)
// ─────────────────────────────────────────────────────────────────

GPS_UPDATE_PACKET:

  // ── Plaintext GPS-Daten (vor Verschlüsselung) ────────────────
  GPS_PLAINTEXT = {
    "lat":          48.137154,           // Breitengrad
    "lng":          11.576124,           // Längengrad
    "alt_m":        519.0,               // Höhe über NN
    "accuracy_m":   8.3,                 // Horizontale Genauigkeit
    "speed_ms":     1.2,                 // Geschwindigkeit m/s
    "bearing_deg":  247.5,               // Himmelsrichtung
    "provider":     "fused",             // GPS / network / fused
    "seq":          42,                  // Sequenznummer (Replay-Schutz)
    "ts":           "2026-03-11T22:14:33.123Z"
  }

  // ── Verschlüsselung ──────────────────────────────────────────
  IV = crypto.randomBytes(12)            // 96-Bit IV — EINMALIG pro Paket

  CIPHERTEXT, AUTH_TAG = AES_256_GCM.encrypt(
    key:       GPS_SESSION_KEY,
    iv:        IV,
    plaintext: JSON.encode(GPS_PLAINTEXT),
    aad:       JSON.encode({             // Authenticated Additional Data
      session_id:     "ses_abc123",
      gps_key_id:     "gpskey_xyz789",
      seq:            42                 // Muss mit plaintext.seq übereinstimmen
    })
  )

  // ── HMAC-Signatur (Integritätsschutz auf Transport-Ebene) ────
  PACKET_HMAC = HMAC_SHA256(
    key:  GPS_SESSION_KEY,               // Gleicher Key → Effizienz
    data: IV + CIPHERTEXT + AUTH_TAG + JSON.encode(aad)
  )

  // ── Finales Payload (an Server gesendet) ─────────────────────
  CLIENT → SERVER:
  POST /api/v2/session/gps-update
  Authorization: Bearer {access_token}
  {
    "session_id":   "ses_abc123",
    "gps_key_id":   "gpskey_xyz789",
    "iv":           "BASE64(IV)",          // 12 Bytes
    "ciphertext":   "BASE64(CIPHERTEXT)",
    "auth_tag":     "BASE64(AUTH_TAG)",    // 16 Bytes GCM Auth-Tag
    "aad":          { "session_id": "...", "gps_key_id": "...", "seq": 42 },
    "hmac":         "BASE64(PACKET_HMAC)"
  }

  SERVER → CLIENT:
  HTTP 200
  {
    "ack":          true,
    "seq_received": 42,
    "server_ts":    "2026-03-11T22:14:33.201Z",
    "latency_ms":   18
  }

// ─────────────────────────────────────────────────────────────────
// SCHRITT 3: Server-seitige Verifikation (Pseudocode)
// ─────────────────────────────────────────────────────────────────

SERVER GpsUpdateHandler.handle(request):

  // 1. JWT validieren
  user = JWT.verify(request.headers.authorization)

  // 2. Session laden
  session = SessionStore.get(request.body.session_id)
  REQUIRE session.user_id == user.id

  // 3. GPS-Key laden (nie im Klartext gespeichert — nur in HSM)
  gpsKey = HSM.retrieveSessionKey(request.body.gps_key_id)

  // 4. HMAC verifizieren (Manipulation auf Transport-Ebene)
  expectedHmac = HMAC_SHA256(gpsKey, request.body.iv + request.body.ciphertext + ...)
  REQUIRE crypto.timingSafeEqual(expectedHmac, request.body.hmac)

  // 5. AES-256-GCM entschlüsseln (Auth-Tag wird automatisch geprüft)
  plaintext = AES_256_GCM.decrypt(
    key:     gpsKey,
    iv:      BASE64.decode(request.body.iv),
    input:   BASE64.decode(request.body.ciphertext),
    authTag: BASE64.decode(request.body.auth_tag),
    aad:     JSON.encode(request.body.aad)
  )
  // Wenn Auth-Tag falsch → Exception → 401 zurück

  // 6. Replay-Schutz: Sequenznummer prüfen
  gpsData = JSON.parse(plaintext)
  REQUIRE gpsData.seq > session.last_gps_seq
  session.last_gps_seq = gpsData.seq

  // 7. GPS in DB persistieren (verschlüsselt mit DB-Key)
  GpsLog.insert({
    session_id: session.id,
    encrypted:  DBEncryption.encrypt(plaintext),  // Doppelt verschlüsselt
    ts:         gpsData.ts
  })

  // 8. Guardian-Push (nur Metadaten — kein Klartext-GPS an Dritte!)
  GuardianSocket.push({
    type:       "gps_update",
    session_id: session.id,
    accuracy_m: gpsData.accuracy_m,   // Genauigkeit ok (kein Klartext-GPS!)
    ts:         gpsData.ts
    // GPS-Klartext nur bei aktivem Alarm freigegeben
  })

  RETURN { ack: true, seq_received: gpsData.seq }

// ─────────────────────────────────────────────────────────────────
// SICHERHEITS-EIGENSCHAFTEN DES SCHEMAS
// ─────────────────────────────────────────────────────────────────
//
//  ✅ Perfect Forward Secrecy (PFS):
//     Ephemeral ECDH-Keys → altes GPS nicht entschlüsselbar bei Key-Kompromiss
//
//  ✅ Replay-Schutz:
//     Sequenznummer + Timestamp + einmaliger IV
//
//  ✅ Manipulationsschutz:
//     AES-GCM Auth-Tag + HMAC-SHA256 (doppelt gesichert)
//
//  ✅ Zero-Knowledge GPS:
//     Guardian sieht nur Metadaten — Klartext-GPS nur bei aktivem Alarm
//
//  ✅ Key-Rotation:
//     GPS_SESSION_KEY läuft nach 1h ab → automatische Neugenerierung

API-Schnittstellen · Phase 1

POST/api/v2/session/gps-key-init
POST/api/v2/session/gps-update
POST/api/v2/session/keyword-alert
GET/api/v2/session/{id}/state
WSS/ws/session/{id}
SEC.SI GERMANY · Lastenheft Phase 1 v1.0 · Vertraulich

Sicherheit Macht SEC.SI