Passa al contenuto principale

SIP Credentials Storage — Correzione cap. 12.5 (v5.15 → v5.16)

Stato: Decisione architetturale consolidata Owner: Ingegnere TLC Akira Data: 2026-05-13 Riferimento docx: cap. 12.5 v5.15 (da correggere in v5.16)


1. Problema identificato

Il cap. 12.5 del docx v5.15 prescrive di memorizzare la password SIP delle device "bcrypt/argon2 in DB sicuro". Questa indicazione è tecnicamente errata nel contesto SIP Digest Authentication.

bcrypt e argon2 sono hash a senso unico, progettati per verificare una password fornita in chiaro contro un hash storato. SIP Digest non funziona così: il client non manda mai la password in chiaro al server, e il server deve poter ricalcolare runtime un valore di confronto a partire da un segreto condiviso.

2. Razionale tecnico (RFC 8760, RFC 7616)

SIP Digest auth flow (semplificato):

  1. Client invia REGISTER o INVITE.
  2. Server risponde 401 Unauthorized con nonce random + realm.
  3. Client calcola:
    HA1 = MD5(username:realm:password)
    HA2 = MD5(method:uri)
    response = MD5(HA1:nonce:nc:cnonce:qop:HA2)
  4. Client invia di nuovo la richiesta con header Authorization contenente response.
  5. Server deve calcolare lo stesso response per validare.

Per il punto 5 il server deve avere HA1 in chiaro (o in forma reversibile). Se HA1 fosse hashato con bcrypt/argon2, il server non potrebbe più ricalcolarlo e l'autenticazione fallirebbe sempre.

Nota: HA1 = MD5(username:realm:password) è già di per sé un hash della password, ma è un hash deterministico e funge da segreto condiviso, non da hash di verifica.

3. Decisione Akira (v5.16)

AspettoDecisione
Cosa si memorizzaHA1 (non la password plaintext)
Algoritmo at-restAES-256-GCM (cifratura reversibile)
ChiaveENCRYPTION_KEY env var, 32 byte hex
Plaintext passwordMAI persistita, mai loggata
Tabelladevices, colonna sip_ha1_encrypted BYTEA
Realmdevices.sip_realm, default akira.asheep.it

Razionale chiave reversibile: HA1 è un segreto condiviso, non un verifier. La cifratura at-rest serve a proteggere il dump del DB / backup; il rischio è mitigato dal fatto che la chiave AES vive in env del processo, non in DB.

4. Schema SQL

-- Rimuovi colonna errata da cap. 12.5 v5.15
ALTER TABLE devices DROP COLUMN IF EXISTS sip_password_encrypted;

-- Nuova colonna corretta
ALTER TABLE devices ADD COLUMN sip_ha1_encrypted BYTEA;
ALTER TABLE devices ADD COLUMN sip_realm VARCHAR(128) DEFAULT 'akira.asheep.it';

-- Estensione pgcrypto richiesta (già installata in tier app DB)
CREATE EXTENSION IF NOT EXISTS pgcrypto;

-- Funzione SECURITY DEFINER: solo chi ha EXECUTE può decifrare,
-- senza dover avere SELECT sulla chiave
CREATE OR REPLACE FUNCTION fn_decrypt_ha1(p_encrypted BYTEA, p_key TEXT)
RETURNS TEXT
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
RETURN convert_from(pgp_sym_decrypt_bytea(p_encrypted, p_key), 'UTF8');
END $$;

REVOKE ALL ON FUNCTION fn_decrypt_ha1(BYTEA, TEXT) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION fn_decrypt_ha1(BYTEA, TEXT) TO akira_kamailio;

-- View per modulo auth_db Kamailio
CREATE OR REPLACE VIEW kamailio_subscriber_view AS
SELECT
d.id,
d.sip_username AS username,
d.sip_realm AS realm,
fn_decrypt_ha1(d.sip_ha1_encrypted, current_setting('app.encryption_key')) AS ha1
FROM devices d
WHERE d.is_active = TRUE
AND d.host_type = 'dynamic'
AND d.sip_ha1_encrypted IS NOT NULL;

GRANT SELECT ON kamailio_subscriber_view TO akira_kamailio;

Il current_setting('app.encryption_key') viene impostato dal pooler/sidecar all'apertura della connessione (SET LOCAL app.encryption_key = '...'). In questo modo Kamailio non vede mai la chiave AES direttamente: la pone come session GUC e la funzione SECURITY DEFINER la usa per decifrare inline.

5. Architettura accessi

Due opzioni valutate:

Opzione A — Sidecar decryption (scartata per MVP)

Microservizio FastAPI espone REST POST /resolve-ha1?username=...&realm=... e Kamailio interroga via http_async_client. Pro: separation of concerns, chiave AES isolata in un container. Contro: latency aggiuntiva (>5ms per ogni auth challenge), single point of failure, codice in più da mantenere.

Opzione B — View pgcrypto + GUC (scelta)

kamailio_subscriber_view con funzione SECURITY DEFINER. Kamailio auth_db legge la view come se fosse una tabella subscriber standard. Pro: zero codice custom, latency sub-ms (single query), Kamailio non vede la chiave AES. Contro: la chiave passa come session GUC — accettabile se l'utente DB di Kamailio è isolato (akira_kamailio) e la connessione è SSL su Tailscale.

Decisione: Opzione B. Documentata in roles/kamailio Ansible playbook.

6. Procedura update password — admin UI flow

  1. Admin apre form Device → SIP Credentials.
  2. UI mostra sip_username (read-only), sip_realm (read-only, da config), campo password vuoto con placeholder •••••••• (no leakage di valore esistente).
  3. Admin inserisce password plaintext, click Save.
  4. Frontend invia POST /api/v1/devices/{id}/credentials con body:
    {"password": "..."}
    Connessione HTTPS, TLS 1.3.
  5. Backend FastAPI:
    import hashlib
    from cryptography.hazmat.primitives.ciphers.aead import AESGCM
    import os

    ha1 = hashlib.md5(
    f"{device.sip_username}:{device.sip_realm}:{payload.password}"
    .encode("utf-8")
    ).hexdigest()

    key = bytes.fromhex(os.environ["ENCRYPTION_KEY"])
    aesgcm = AESGCM(key)
    nonce = os.urandom(12)
    ct = aesgcm.encrypt(nonce, ha1.encode("utf-8"), None)
    sip_ha1_encrypted = nonce + ct # prepend nonce per decrypt
  6. UPDATE devices SET sip_ha1_encrypted = :ct WHERE id = :id.
  7. Plaintext password scartata subito (variabile out-of-scope). MAI:
    • persistita in DB,
    • loggata (logger config blocklist sul campo password),
    • inviata a Telegram/email,
    • inclusa in audit trail (audit trail registra solo "credentials rotated at by <admin_user>").
  8. Risposta API: 204 No Content. UI conferma "Password updated".

7. Verifica e operations

  • Test integrazione: Kamailio kamcli mi auth_db.dump_subscriber deve ritornare HA1 in chiaro per device dynamic.
  • Key rotation: nuova ENCRYPTION_KEY → job batch rotate_ha1_keys decifra con vecchia, ricifra con nuova, atomic in transaction. Procedure in runbook ops/key-rotation.md (v5.16 TBD).
  • Backup DB: backup logici (pg_dump) contengono sip_ha1_encrypted cifrato — la chiave AES NON è nel backup. Restore richiede chiave AES separata da vault Ansible.

8. Note di sicurezza residue

  • MD5 è debole crittograficamente, ma è imposto da RFC SIP Digest. RFC 8760 permette SHA-256/SHA-512-256 → roadmap v2 (vedi signaling-decisions.md).
  • AES-GCM richiede nonce univoco per coppia chiave/plaintext: generato random 12 byte, probabilità collisione trascurabile (<2^-32 a 10^9 records).
  • Compromise del DB + env del backend = compromise totale credenziali. Mitigation: env in Ansible vault, accesso SSH con MFA, audit log su fn_decrypt_ha1 calls.

9. Patch da applicare a docx v5.16

  • Cap. 12.5: sostituire integralmente sezione "Storage password SIP". Nuovo testo basato su questo doc.
  • Cap. 9.11 (Devices schema): aggiungere colonne sip_ha1_encrypted, sip_realm. Rimuovere riferimento sip_password_encrypted.
  • Cap. 25 (Security): aggiungere paragrafo "Encryption-at-rest per credenziali SIP" con riferimento a ENCRYPTION_KEY env e procedura key rotation.
  • Glossario: aggiungere voce HA1.