Università di Bologna

Corso di

RETI DI CALCOLATORI

Anno Accademico 2000-2001

 

TITOLO DEL PROGETTO

Il telefonino del WEB.

 

AUTORI

DATA DELLE REVISIONI

2 Marzo 2001

 

OBIETTIVI

 

ANALISI DEL PROBLEMA

Quello che si vuole realizzare è un sistema software in grado di far comunicare tipo "chat" due o più persone mediante Internet, assicurando autenticazione e riservatezza dei dialoghi; l'applicazione deve tenere conto di host aventi indirizzo IP dinamico.

La semplicità d'uso è un requisito primario cui non si può rinunciare.

Le seguenti ipotesi saranno d'aiuto nello sviluppo dell'intero progetto:

 

PROGETTO

Il sistema software da realizzare deve costituire l'endpoint di una chatline sicura; semplificando la cosa si può vederlo come nodo sia client, sia server di una versione crittografata e decentrata del noto applicativo ICQ (www.icq.com).

La struttura globale risulta una rete peer-to-peer, perché tutti i nodi hanno la medesima importanza, fungendo sia da client che da server a seconda delle esigenze del momento.

La fase di "Analisi del problema" suggerisce una prima schematizzazione dell'architettura software del sistema:

Indicando col termine "agente di controllo" l'entità software che coordina le attività degli oggetti che costituiscono l'applicativo, si può affermare che esso deve gestire interazioni sollecitate sia dall'utente per mezzo dell'interfaccia, sia dalla rete:

Ciascun utilizzatore è assimilabile ad un possibile destinatario di Contatto, dotato dei seguenti attributi:

  1. identità, nominativo o nickname che sia;
  2. chiave crittografica;
  3. lista dei possibili indirizzi a cui risponde.

I primi due attributi sono costanti e legati permanentemente alla persona in questione, mentre il terzo è modificabile a piacere, purché costituisca sempre un riferimento preciso tramite il quale raggiungere uno user. Non si confonda il termine "indirizzo" qui usato con "indirizzo IP", perché ora si sta parlando di riferimento, astratto ma preciso, ad una residenza di persone. Teoricamente è ammessa la possibilità che alla medesima locazione possano rispondere più utenti distinti, ma non contemporaneamente: quando un indirizzo è "in attività", cioè coinvolto in un dialogo, esso individua sempre una ed una sola persona.

Si introduce innanzitutto l'Abstract Data Type (ADT) Contatto:

Le istanze dell'ADT sono puri valori oppure oggetti dotati di stato modificabile? Sono oggetti dotati di stato modificabile.
L'ADT è atomico oppure strutturato? È un tipo di dato strutturato, entro cui si possono distinguere:
  • un nome, inteso come stringa di lunghezza compresa fra 1 e 32, equivalente all'identità: rappresenta il nickname usuale delle chat, quello con il quale ciascun individuo è noto agli altri utenti del servizio. Non importa che sia realistico oppure dettato dalla fantasia.
    Evitando la complessità di costruire uno spazio di nomi, si fissa nei caratteri validi per gli identificatori Java l'insieme di validità;
  • una chiave_crittografica: sono ammesse chiavi simmetriche o asimmetriche;
  • una lista di Endpoint, ovvero una sequenza non vuota di riferimenti di rete con cui dialogare, idealmente illimitata in lunghezza.
Quali selettori e predicati è più opportuno introdurre? Ci sono tre selettori:
  • String nome(): restituisce l'attributo testuale nome;
  • Key chiave(): riporta il valore del campo chiave_crittografica;
  • Endpoint[] dove(): lettura della successione di Endpoint associata all'oggetto; contiene sempre almeno un elemento;

ed un predicato:

  • boolean carLecitoNome(char c): non riguarda tanto un oggetto Contatto, quanto piuttosto il tipo Contatto stesso. Infatti, per decidere se un carattere può far parte del nome quest'operazione non ha bisogno di processare alcun oggetto in particolare. È introdotto per comodità; è un metodo di classe.
    Restituisce true <=> il carattere c è alfabetico, maiuscolo o minuscolo che sia, oppure numerico, oppure '_', oppure '$'; false altrimenti.
Quali modificatori inserire? void aggiungi(Endpoint e): accoda e nella lista di Endpoint propria di ciascuna istanza.
L'insieme delle operazioni (primitive) definite è funzionalmente completo? Sì, perché i tre selettori forniti consentono di avere accesso a tutti gli elementi costitutivi di un oggetto Contatto, ed il modificatore incrementa il contenuto dello stato interno dell'oggetto.
Quali operazioni dipendono dalla rappresentazione concreta del tipo? Escludendo il predicato, le quattro operazioni sono delle primitive.
Qual è l'invariante dell'ADT? Il nome è sempre una sequenza composta da 1 a 32 caratteri, ciascuno dei quali soddisfa il predicato di classe carLecitoNome().

La lista degli Endpoint non è mai vuota.

Quali elementi costituenti occorrono per formare un'istanza dell'ADT? Un nome, una chiave crittografica ed un Endpoint sempre; in seguito si possono aggiungere ulteriori Endpoint grazie al modificatore.

La costruzione di un oggetto fallisce se:

  • la lunghezza del nome proposto supera i 32 caratteri oppure è nulla;
  • la stringa nominale ricevuta in ingresso contiene un carattere c che non soddisfa la condizione carLecitoNome(c).

dove l'ADT Endpoint è così definito:

Le istanze dell'ADT sono puri valori oppure oggetti dotati di stato modificabile? Puri valori.
L'ADT è atomico oppure strutturato? Strutturato, composto da un indirizzo IP ed un numero di porta TCP/UDP.
Quali selettori e predicati è più opportuno introdurre? Solamente due selettori:
  • InetAddress IP(): restituisce il campo contenente l'IP address;
  • int porta(): effettua la lettura del field associato al SAP.
Quali modificatori inserire? Nessuno.
L'insieme delle operazioni (primitive) definite è funzionalmente completo? Sì, perché si può accedere in sola lettura ai singoli membri costituenti l'oggetto.
Quali operazioni dipendono dalla rappresentazione concreta del tipo? I due selettori sono delle primitive.
Qual è l'invariante dell'ADT? 0 £ porta() £ 65535 º 0xFFFF.
Quali elementi costituenti occorrono per formare un'istanza dell'ADT? All'atto della costruzione sono richiesti un indirizzo IP ed un numero di porta che rispetti l'invariante dell'ADT. Qualora ciò non avvenga si produce un fallimento.

Andando per ordine, si valutino ora i singoli elementi mostrati nella prima figura uno per volta.

 

Interfaccia

Il suo compito è astrarre le richieste sollecitate dall'utente e visualizzare le risposte prodotte dall'agente di controllo.

Visto il tipo di interazione con lo user, quest'entità funge da generatore di eventi asincroni, i quali saranno poi processati dall'agente.

Si conviene di poter mostrare all'utente:

L'interazione dell'entità in esame con l'agente di controllo è regolata dagli eventi originati dalla GUI di sistema, i quali portano con sé un indicatore del tipo di azione da compiere; essi rappresentano delle richieste di servizio che possono essere soddisfatte o meno per decisione dell'agente stesso.

Ciò costituisce un modello push delle informazioni, realizzato con azioni asincrone (= prive di risposta) non bloccanti: generata la richiesta sotto forma di evento, sarà l'agente di controllo a decidere se produrre o meno una risposta, aggiornando di conseguenza l'interfaccia mediante messaggi esplicativi o simili.

 

Database

L'adozione di un database esterno allargherebbe in modo considerevole le dimensioni del programma, appesantendo oltremodo le risorse necessarie per la sua esecuzione.

In un'ottica UNIX si introduce quindi un file di testo, all'interno del quale l'utente ed il software possono registrare tutti i Contatti noti, secondo una precisa grammatica da definirsi; questa scelta assicura la persistenza delle informazioni fra attivazioni successive, assieme a modularità ed apertura del sistema.

Per elaborare il file ASCII sono sufficienti un lexer ed un parser che estraggono i Contatti uno dopo l'altro, dopodiché il database li memorizza in una sequenza ordinata in modo crescente sulla base dell'attributo nome, con lo scopo di velocizzare le operazioni di ricerca, di tipo "binaria" (o "dicotomica") per questioni di efficienza.

Ecco una possibile grammatica adatta alle esigenze manifestatesi:

scopo ::= nome / chiave @ endpoint

nome ::= lettera | cifra | _ | $ | lettera nome | cifra nome | _ nome | $ nome

lettera ::= a | b | … | y | z | A | B | … | Y | Z

chiave ::= numero | numero , chiave

endpoint ::= ip : porta | ip : porta @ endpoint

ip ::= numero . numero . numero . numero

porta ::= numero

numero ::= 0x esadecimale | intero

esadecimale ::= cifra-hex | cifra-hex esadecimale

intero ::= cifra | cifra intero

cifra ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

cifra-hex ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | a | b | c | d | e | f | A | B | C | D | E | F

che ulteriormente sintetizzata mediante la notazione BNF (Bakus-Naur Form) diviene:

scopo ::= nome / chiave @ endpoint

nome ::= ( a |…| z | A |…| Z | 0 |…| 9 | _ | $ ) { a |…| z | A |…| Z | 0 |…| 9 | _ | $ }31

chiave ::= numero { , numero }

endpoint ::= ip : numero { @ ip : numero }

ip ::= numero . numero . numero . numero

numero ::= 0 [ x cifra-hex { cifra-hex } | { cifra } ] | cifra-non-nulla { cifra }

cifra ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

cifra-non-nulla ::= 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

cifra-hex ::= 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | a | b | c | d | e | f | A | B | C | D | E | F

Trattasi di grammatica di tipo 3 lineare a destra: un parser a discesa ricorsiva è in grado di interpretarla senza ambiguità. La semantica dei numeri è demandata al controllo esterno al lexer, cioé al parser, il quale deve sincerarsi che i numeri relativi ad un IP, ad esempio, rientrino nel range dei byte, e così via.

E' introdotto ora l'ADT Database che ne consegue:

Le istanze dell'ADT sono puri valori oppure oggetti dotati di stato modificabile? Sono oggetti aventi stato modificabile.
L'ADT è atomico oppure strutturato? Un Database è un ente strutturato in cui si possono distinguere un primo, un secondo, un terzo, … elemento.
Quali selettori e predicati è più opportuno introdurre? Si definiscono i selettori:
  • Contatto seleziona(String chi): restituisce il Contatto avente nome uguale a chi, oppure un valore nullo qualora non esistano elementi che soddisfino l'uguaglianza;
  • Contatto[] tutti(): estrae tutti i record organizzandoli in sequenza ordinata; nell'eventualità di archivio vuoto tale successione è priva contenuto.
Quali modificatori inserire? void modifica(Contatto c): cerca nell'archivio un elemento il cui attributo nome eguagli il medesimo campo di c: se esiste allora ne altera il valore ponendolo uguale a c, altrimenti si aggiunge una nuova entità posizionandola secondo un ordine crescente in base al campo (chiave) nome.

Questa realizzazione evita la presenza di due entità Contatto aventi lo stesso nominativo, centralizzando i possibili punti d'ingresso nell'archivio.

L'insieme delle operazioni (primitive) definite è funzionalmente completo? Sì, perché:
  • l'operazione tutti() permette di ottenere i record contenuti, contandoli;
  • seleziona() ne individua al più uno, sulla base del nome;
  • modifica() altera e/o introduce singoli Contatto.

Non sono previste azioni di cancellazione degli elementi, come specifica di definizione dell'ADT stesso.

Quali operazioni dipendono dalla rappresentazione concreta del tipo? Le tre operazioni illustrate sono tutte delle primitive.
Qual è l'invariante dell'ADT? Un oggetto Database è sempre costituito da una sequenza ordinata, in senso crescente, di zero o più elementi Contatto, i quali hanno l'attributo nome unico all'interno dell'istanza di ADT.

La lunghezza della sequenza di record è non decrescente; seleziona() e tutti() non influiscono sul numero di elementi contenuti, mentre modifica() può accrescerlo di una unità al massimo per ogni invocazione.

Quali elementi costituenti occorrono per formare un'istanza dell'ADT? Nessuno, in quanto è sufficiente creare un archivio inizialmente vuoto, dopodiché il modificatore illustrato costituirà il mezzo per introdurre un qualsivoglia numero di Contatto.

 

PROTOCOLLO CRITTOGRAFICO

Dovendo trattare problematiche di autenticazione, integrità e riservatezza, la crittografia a chiave pubblica è uno strumento idoneo ad assolvere a tutti questi compiti, tantopiù che oggigiorno la sua adozione nelle strutture informatiche rivolte al pubblico è in continua crescita.

Reperire strumenti che supportano queste tecnologie è certamente facile, quindi perché non approfittarne? perché rinunciarvi dovendo progettare complessi protocolli a sfida o simili?

L'unico aspetto negativo è il carico computazionale richiesto dalla crittografia in generale, ma poiché l'applicazione è di tipo elastica-interattiva il problema non si pone.

Come prima cosa è necessario attribuire una coppia di chiavi a ciascun utente; l'algoritmo universalmente usato è RSA (Rivest-Shamir-Adleman), mentre la dimensione delle chiavi la si sceglie in base alle esigenze ed al livello di sicurezza desiderato: 1024 bit sono più che sufficienti oggigiorno.

Trattandosi di cifratura a blocchi occorre suddividere il plaintext in unità di dimensioni fisse opportune, scegliendo anche una regola di padding; gli autori di RSA suggeriscono PKCS#7 per questo secondo problema (si veda www.rsa.com a tal proposito), mentre per la frammentazione dei messaggi conviene lavorare in ECB (Electronic Code Book), perché tutte le alternative ad esso obbligano a condividere ulteriori informazioni all'atto della decifrazione.

Va rilevato che il modo ECB tratta ciascun blocco indipendentemente dagli altri, dunque un ipotetico intruso potrebbe facilmente distinguere ogni frammento all'interno di un lungo messaggio; in seguito a ciò si deve fornire un check d'integrità complessivo del pacchetto informativo scambiato tramite la rete.

La scelta più logica per garantire sia l'integrità del messaggio, sia l'autenticazione del mittente è costituita dalla firma digitale: le chiavi asimmetriche adottate per RSA fanno all'uopo, garantendo la riservatezza del dialogo cifrando la sequenza [plaintext + firma digitale] con la chiave pubblica del ricevente. A tal punto solo il reale destinatario sarà in grado di risalire al vero contenuto della "busta" pervenutagli, verificandone sia l'integrità che la provenienza grazie alla firma digitale inclusa in essa.

Come alternativa si possono adottare due cifrature consecutive mediante RSA: prima impiegando la chiave privata del mittente, poi quella pubblica del destinatario; il livello di sicurezza offerto da questa soluzione è maggiore di quello precedente, in virtù del fatto che la cardinalità dello spazio delle firme digitali di un messaggio è minore della cardinalità dello spazio dei messaggi stessi. Occorre evidenziare però che due passaggi di RSA richiedono molta elaborazione per il processore, quindi non sempre sono convenienti in termini di tempo.

Entrambi i metodi illustrati possiedono un vizio di fondo comune: non sono utilizzabili in ambiti di gruppo, nel senso che è impensabile creare un messaggio specifico per ciascun destinatario quando il numero di partecipanti può essere di una o più decine, ad esempio.

Una risposta a questo problema consiste nella chiave segreta (simmetrica) di sessione, la quale permette di far fronte a molteplici aspetti pratici, perché computazionalmente la crittografia simmetrica è molto più leggera, inoltre la generazione di una chiave dedicata per ciascun dialogo rende più ardua la vita di un intruso che voglia intercettare i dialoghi. Modificare la chiave di sessione ad ogni dialogo consente di ottenere differenti messaggi a livello di rete, anche quando il loro contenuto non cambia.

Resta inteso che lo scambio iniziale della chiave di sessione fra i partecipanti avviene secondo lo schema iniziale, facendo riferimento alla firma digitale seguita da cifratura mediante RSA.

Riassumendo:

PROTOCOLLO CRITTOGRAFICO

VANTAGGI

SVANTAGGI

Singola (de)cifratura a chiave asimmetrica con firma digitale
  • Riservatezza;
  • autenticazione del mittente;
  • integrità del messaggio (formalmente meno sicura del metodo successivo).
  • La composizione del pacchetto finale deve poter distinguere tra messaggio e firma;
  • non applicabile per dialoghi di gruppo;
  • soggetto a replay attack.
Doppia (de)cifratura con chiavi asimmetriche
  • Riservatezza;
  • autenticazione del mittente;
  • integrità del messaggio (solo a blocchi?).
  • Computazionalmente molto oneroso;
  • non applicabile per dialoghi di gruppo;
  • soggetto a replay attack.
Chiave segreta (simmetrica) di sessione con firma digitale
  • Riservatezza;
  • autenticazione del mittente;
  • integrità del messaggio;
  • computazionalmente è la soluzione più vantaggiosa;
  • idoneo anche per gruppi di discussione;
  • non soggetto a replay attack fra sessioni diverse, purché la chiave segreta cambi ogni volta.
  • Overhead iniziale dovuto alla generazione ed allo scambio della chiave di sessione;
  • la composizione del pacchetto finale deve poter distinguere tra messaggio e firma;
  • soggetto a replay attack nell'ambito della stessa sessione.

La terza soluzione proposta è interessante soprattutto per la sua versatilità, in quanto adottabile sia in contesti di coppia che di gruppo. Con un solo protocollo crittografico si fa fronte ad entrambe le tipologie di conversazioni; implementativamente è un grosso sollievo! Introduce meno codice, quindi un minor numero di possibili errori progettuali, implementativi o di codifica; ne beneficia anche la semplicità dell'intero apparato software.

L'algoritmo crittografico a chiave segreta può essere IDEA, Triple DES, Blowfish, RC4 o simili: in questa sede si opta per IDEA, perché di elevata robustezza ed universalmente noto in ambito informatico, perciò facilmente realizzabile. Sono comuni i tool software che lo includono, in qualsiasi linguaggio. Da sottolineare anche le buone prestazioni di IDEA in termini di velocità.

Osservazioni:

 

PROTOCOLLO DI TRASMISSIONE

Avendo dialoghi distribuiti ad intervalli impredicibili nel tempo l'adozione di un protocollo connection-oriented come TCP è sinonimo di "spreco", perché impegna risorse di rete anche quando non c'è scambio di messaggio fra gli utenti. La sua adozione risulterebbe poi sovrabbondante durante conversazioni di gruppo, con un canale bidirezionale per ciascuna coppia di interlocutori: n partecipanti richiederebbero n·(n-1):2 stream full-duplex, µ n2!

Scartando TCP resta a disposizione solo UDP: essendo connectionless la cosa è soddisfacente per quanto concerne l'impegno di risorse, sia come traffico di rete, sia come elaborazione locale richiesta nel trattamento dei singoli datagrammi.

La caratteristica unreliable di UDP obbliga a cercare una soluzione per informare il mittente dell'avvenuta ricezione di quanto inviato: semplicità ed efficienza orientano immediatamente verso Automatic Repeat reQuest (ARQ). Valori ammissibili di timeout sono 5÷10 secondi, mentre 3÷5 ripetizioni per ciascun datagramma sul lato del mittente costituiscono un'insistenza non esagerata ma sufficientemente accorta. Si noti come l'elaborazione crittografica dell'applicativo, caricando il processore, possa far propendere per timeout di 8÷10 secondi piuttosto che 5, a meno di usare concorrenza ed eventi asincroni all'interno del software, disaccoppiando la computazione vera e propria dalla gestione della rete.

La concorrenza in termini di multithreading sarebbe la soluzione più elegante, però la preemption non assicurata dallo scheduler della JVM e la semplicità richiesta all'applicazione consigliano una metodologia a polling: periodicamente si interroga l'interfaccia di rete, dandole precedenza qualora essa segnali attività UDP, per poi riprendere solo in seguito l'elaborazione sospesa.

In questo modo l'applicativo possiede un unico flusso di esecuzione, il quale saltuariamente si occupa di testare gli apparati di rete per concedere loro, eventualmente, la capacità computazionale necessaria per trattare datagrammi UDP.

Adoperando opportunamente gli eventi asincroni generati dall'interfaccia è possibile creare un main-loop dedicato alla porta UDP di ascolto, il quale viene interrotto se e solo se lo user richiede operazioni: qualora queste risultino prolungate nel tempo è possibile adottare anche il polling descritto, altrimenti l'asincronia offerta dalla GUI è più che sufficiente.

È necessaria la frammentazione/ricomposizione di messaggi a livello applicativo in più datagrammi UDP?

Ecco una stima basata sui seguenti dati sperimentali:

dunque a fronte di un ipotetico messaggio a livello di applicazione, composto da 1000 byte, si ottiene un datagramma di:

128 / 112 * (1000 + 128) @ 1290 byte (+ header UDP di 8 byte)

ai quali si possono aggiungere alcuni byte di padding per la cifratura a blocchi, oltre ad un eventuale sequence number al di sopra di UDP: 1400 byte è una stima altamente pessimistica, considerando che parte dell'overhead introdotto è costante con la lunghezza del plaintext.

Il campo dati di un datagramma UDP è composto al massimo 65527 byte (0xFFFF - 8 di header UDP) => non è richiesta frammentazione/ricomposizione per messaggi applicativi fino a 56 Kbyte circa; tale valore discende dall'applicazione inversa della succitata formula.

L'interfaccia seguente astrae le azioni che si possono richiedere:

public interface ITrasmissione {

  int invia(DatagramPacket[] d);

  int disponibili();

  void ricevi(DatagramPacket *d);

  void chiudi();

}

dove:

È necessario relazionare il protocollo di trasmissione con quello crittografico? In caso affermativo, qual è il legame fra i due?

Si considerino gli ACK: in intruso esterno, dotato di sniffer di rete, può appropriarsi dei datagrammi in transito, producendo un acknowledge per il mittente anche quando il vero destinatario non viene raggiunto dalle informazioni a lui indirizzate => la crittografia deve proteggere l'ACK stesso.

Si può cifrare il sequence number del messaggio da spedire, dopodiché sarà solo il vero destinatario a poterne ricavare il valore numerico per produrre la conferma associata. Qualora i sequence number siano casuali fra messaggi successivi non è necessario applicare la cifratura dell'ACK: se l'intruso legge il numero della conferma non può utilizzarlo a suo favore, perché i prossimi datagrammi avranno certamente numeri diversi, non correlati (o quasi...) a quello osservato al momento.

Questa gestione degli acknowledge incarna un "protocollo a sfida" fra il mittente ed il ricevente.

La mancata cifratura dei messaggi di conferma alleggerisce anche l'elaborazione matematica richiesta al processore!

Questa stretta correlazione fra la parte crittografica ed il protocollo di trasmissione non permette di asserire che si viene a costituire un vero e proprio livello di presentazione, in quanto esso collabora attivamente con la sottostante infrastruttura di trasmissione per realizzarne le funzionalità. I due livelli non sono autonomi presi singolarmente.

La modularità dell'architettura spinge ad incapsulare entro una classe base SocketUDPaffidabile il protocollo di trasmissione, lasciando che sia una sua specializzazione a gestire materialmente i numeri di sequenza. In questo modo, grazie all'ereditarietà, è possibile riutilizzare il pattern di socket UDP reliable in ambiti non crittografati, ad esempio, oppure specializzando la classe per esigenze particolari:

L'adozione di un socket UDP per scambiare informazioni con più endpoint remoti obbliga a conservare una "storia" riferita a ciascun mittente remoto: il numero di sequenza dell'ultimo messaggio ricevuto. Questo serve per non replicare più volte lo stesso segmento ricevuto passandolo al livello di applicazione; è la soluzione adottata da TCP.

Ecco i passi elementari da effettuare all'atto di una ricezione:

 

[lettura del datagramma UDP dallo stack TCP/IP];
seq_num := [numero di sequenza estratto dal datagramma];
IF (seq_num > last_seq_num ricevuto dallo stesso endpoint) THEN BEGIN
  [per l'endpoint di provenienza del datagramma porre last_seq_num := seq_num];
  [consegna del datagramma al livello OSI superiore];
END;
ELSE
  [scarto del datagramma, perché già ricevuto in precedenza];

Più rigorosamente, l'ADT SocketUDPaffidabile è così definito:

Le istanze dell'ADT sono puri valori oppure oggetti dotati di stato modificabile? Oggetti dotati di stato modificabile.
L'ADT è atomico oppure strutturato? L'ADT è strutturato al suo interno, ma questo non deve apparire osservandolo dall'esterno; si possono individuare:
  • un socket UDP sock, usato per inviare/ricevere i datagrammi, il cui numero di porta può essere suggerito dal programmatore oppure scelto dal sistema operativo sottostante;
  • una history, intesa come lista contenente il sequence number numericamente più alto ricevuto da ciascun endpoint con il quale si sono intrattenuti degli scambi. Questa struttura risolve il problema delle ricezioni multiple dello stesso datagramma.
    Per ciascun endpoint essa contiene uno ed un solo valore numerico.
    All'avvio questa lista è vuota, dopodiché cresce per ogni nuovo endpoint che invia datagrammi al socket sock;
  • una cache FIFO dei datagrammi estratti da sock ma non ancora consegnati al layer sovrastante, usando una terminologia OSI; il perché di questo verrà illustrato in seguito.
Quali selettori e predicati è più opportuno introdurre? Si introduce la seguente proprietà:
  • int disponibili(): restituisce il numero di datagrammi UDP che possono essere letti senza bloccarsi sul socket affidabile; il valore richiesto è dato dalla somma dei messaggi entro la cache e da quelli pronti alla lettura tramite sock:
    RETURN cache.lunghezza() + sock.disponibili();
Quali modificatori inserire?
  • int invia(DatgramPacket[] d): operazione sincrona bloccante che cerca di recapitare tutti i datagrammi UDP d ricevuti come parametro di ingresso. Restituisce il numero di consegne andate a buon fine: nella migliore dell'ipotesi tale valore è pari alla lunghezza del vettore d, nella peggiore ritorna zero.
    Il metodo compie le seguenti azioni:
    WHILE (ci sono datagrammi disponibili su sock) DO BEGIN
      sock.ricevi(temp);
      IF (temp è un acknowledge) THEN
        CONTINUE;
      seq_num := [numero di sequenza estratto da temp];
      sock.invia(seq_num, temp.mittente());
      IF (temp non è mai stato ricevuto) THEN BEGIN
        cache.aggiungi(temp);
        history.aggiorna(temp.mittente(), seq_num);
      END;
    END;
    [aggiunta dei sequence number ai datagrammi d];
    FOR i := 1 TO tentativi_per_datagramma DO BEGIN
      sock.invia(d);
      WHILE (timeout non scaduto) DO
        IF (ci sono datagrammi disponibili su sock) THEN BEGIN
          sock.ricevi(risposta);
          IF (risposta è un acknowledge) THEN
            IF (risposta è un acknowledge valido per d) THEN
              [tieni traccia che questa consegna è andata a buon fine];
            ELSE
              [scarto dell'acknowledge fasullo];
          ELSE BEGIN
            seq_num := [numero di sequenza estratto da temp];
            sock.invia(seq_num, temp.mittente());
            IF (temp non è mai stato ricevuto) THEN BEGIN
              cache.aggiungi(temp);
              history.aggiorna(temp.mittente(), seq_num);
            END;
          END;
        END;
    END;
    RETURN [il numero di consegne riuscite];
  • void ricevi(DatagramPacket *d): al termine di questa operazione il parametro d contiene il primo messaggio UDP giunto a sock che non è ancora stato letto. Prima di accedere direttamente a sock occorre verificare la cache: qualora non ci siano datagrammi pronti l'azione diviene bloccante, effettuando la lettura da sock:
    IF (cache.lunghezza() > 0) THEN BEGIN
      d := cache.testa();
      cache.cancella(cache.testa());
    END;
    ELSE
      sock.ricevi(d);   /* Bloccante, in generale... */
  • void chiudi(): rilascia le risorse occupate dal socket invocando:
    sock.chiudi().
L'insieme delle operazioni (primitive) definite è funzionalmente completo? Sì.
Quali operazioni dipendono dalla rappresentazione concreta del tipo? Le quattro operazioni illustrate sono delle primitive.
Qual è l'invariante dell'ADT?
  • 0 £ invia(d) £ d.lunghezza();
  • disponibili() ³ 0.
Quali elementi costituenti occorrono per formare un'istanza dell'ADT? Il numero della porta UDP alla quale legare il socket sock, eventualmente, oppure lasciare che sia il S.O. sottostante ad occuparsene, prendendone una disponibile a runtime.

L'Abstract Data Type appena illustrato prevede anche due operazioni che devono essere obbligatoriamente implementate dalle classi derivate:

In questo modo è possibile specializzare SocketUDPaffidabile riscrivendo solo i due metodi d'istanza sovrastanti, i quali diventano così per il caso in esame:

Nel pseudo-codice illustrato la variabile seq_num è un campo d'istanza interno all'ADT, inteso come un contatore intero. È una sorta di accumulatore che viene incrementato di una quantità random ad ogni nuovo datagramma da spedire in rete.

 

PROTOCOLLO DI DIALOGO

Quali messaggi devono scambiarsi i singoli nodi della rete per chattare fra loro?

Ricalcando alcune caratteristiche di TCP, i datagrammi si possono classificare in cinque tipologie distinte:

Ecco un esempio nel caso in cui lo user A voglia comunicare con B, il quale sta già colloquiando con altri:

A

¾¾¾¾¾ SYN ¾¾¾¾®

¬¾¾¾¾ OK ¾¾¾¾¾

B
    ¾¾¾ ADD ¾¾®

...

¾¾¾ ADD ¾¾®

 

¾¾¾¾¾TEXT¾¾¾¾®

¬¾¾¾¾TEXT¾¾¾¾¾

...

¬¾¾¾¾ FIN ¾¾¾¾¾

 

Ciascuna freccia rappresenta un datagramma UDP affidabile.

Fra il SYN ed il corrispondente OK non ci sono vincoli di tempo: questo consente a B di riflettere prima di accettare la richiesta pervenutagli da parte di A.

Una richiesta è tacitamente negata sino a quando non viene espressamente confermata dall'OK.

Nel caso in cui B fosse già a colloquio con altri utenti il messaggio di OK informa A di essere in contatto non solo con B, ma anche con altri individui; in questi frangenti A può allargare il proprio giro di conoscenze, eventualmente aggiungendo i nuovi "amici" al proprio database interno che funge da rubrica.

E' compito di B informare i suoi interlocutori di aver concesso ad A il permesso di intervenire nel colloquio in atto, inviando un datagramma di ADD recante con sé il nickname, la chiave pubblica e l'endpoint di A.

Come realizzare il groupcast per i messaggi di tipo TEXT?

Una politica fair incarica colui che interviene nella discussione di recapitare le proprie asserzioni a tutti gli interessati, ossia a tutti i componenti del gruppo di discussione. Questa è la soluzione più semplice.

 

AGENTE DI CONTROLLO

Quest'entità incapsula il coordinamento complessivo di tutto il sistema. Dal momento che il legame con il protocollo crittografico è molto forte (per funzionare quest'ultimo necessita delle due chiavi RSA dell'utilizzatore, della rubrica con le chiavi pubbliche, della lista degli interlocutori e della chiave di sessione), la prima conseguenza è che l'agente deve inglobare al suo interno il protocollo crittografico stesso. Si ricordi però che questi specializza SocketUDPaffidabile, quindi la situazione è presto schematizzata:

SocketUDPaffidabile contiene il protocollo di trasmissione, mentre quelli crittografico e di dialogo sono inglobati entro l'agente di controllo.

Dal punto di vista algoritmico, l'agente esamina sia le richieste provenienti dall'utente tramite l'interfaccia, sia i datagrammi ricevuti dalla rete tramite SocketUDPaffidabile, formulando le eventuali risposte ed assicurandosi che il protocollo di dialogo sia rispettato.

Nella fase di avvio egli deve:

  1. reperire le due chiavi RSA dello user;
  2. avviare il database (rubrica) a partire dai dati persistenti su memoria di massa;
  3. aprire il socket UDP per gli scambi di informazioni tramite rete;
  4. creare un archivio vuoto, destinato a contenere gli interlocutori appartenenti al gruppo di discussione; vista la dinamicità di quest'ultimo, l'archivio richiede sia operazioni di inserimento che di cancellazione.

In fase di terminazione del programma occorre:

  1. riscrivere su file system il database usato come rubrica, qualora il suo contenuto sia mutato a runtime;
  2. chiudere il dialogo inviando un messaggio di FIN a tutti i componenti del gruppo di discussione;
  3. rilasciare le risorse occupate dal socket invocando il metodo chiudi() dell'ADT SocketUDPaffidabile.

Il Class Diagram complessivo è così composto:

mentre il Collaboration Diagram è il seguente:

 

IMPLEMENTAZIONE

E' già stata fissata Java 1.2 come piattaforma di sviluppo; il supporto crittografico è realizzato dal package Cryptix (www.cryptix.org) nella sua attualmente più recente versione, la 3.2.0, in quanto Open Source e sufficientemente completo.

Tutto il codice che riguarda il progetto è racchiuso dentro il package chat.

Procedendo secondo l'ordine seguito in fase di progetto si introduce una public class Endpoint, la quale è perfettamente definita dall'omonimo ADT; l'unica aggiunta da apportare è la generazione di una IllegalArgumentException in fase di costruzione, qualora il numero di porta specificato come parametro non rientri nell'intervallo chiuso [0,65535].

Essendo in ambiente Java, può risultare utile ridefinire il metodo public boolean equals(Object obj) per effettuare il confronto fra due istanze di Endpoint, restituendo true se e solo se obj != null ed i campi private dell'istanza e di obj sono uguali fra loro.

E' semplice anche implementare l'Abstract Data Type Contatto:

L'interfaccia dell'applicativo, in un'ottica di semplicità estrema, può essere mantenuta di tipo "testuale", evitando di ricorrere ai package java.awt e java.swing che obbligano all'adozione di una GUI a runtime. Questo però solleva un problema: l'asincronia massima offerta dai canali standard di I/O è quella associata al polling dei medesimi, metodo che, secondo prove sperimentali effettuate, rende inutilizzabile la console, perché rallenta esageratamente la visualizzazione dei caratteri inseriti dall'utente, nascondendoli del tutto in certe situazioni.

Una possibile soluzione per non rinunciare alla modalità "testuale" è spezzare in due parti l'applicativo: un primo programma legge perennemente da System.in inviando i dati su un file, mentre un secondo prende le linee di testo dal supporto di massa precedente per elaborarle e produrre le eventuali risposte su System.out. In sintesi, la prima applicazione gestisce la propria finestra come canale di input della chat, mentre la seconda realizza lo schermo di visualizzazione del dialogo:

Visti i problemi causati dal polling di System.in, si ritiene opportuno maneggiare SocketUDPaffidabile da parte del canale di uscita della chat.

Questa suddivisione obbliga a definire un protocollo secondo il quale le due parti comunicano, introducendo semplici linee di comando per lo user finale:

Per comodità, il file di scambio delle informazioni viene gestito a linee di testo: ogni linea definisce un comando.

Si indica con Console la public class che realizza l'interfaccia di ingresso del sistema, mentre public class Monitor identifica la parte di output. Esse saranno descritte solo al termine della fase d'implementazione, perché maneggiano oggetti non ancora implementati.

Proseguendo, l'ADT Database può essere implementato come wrapper attorno ad un java.util.SortedSet, il quale permette una gestione ad albero binario di un insieme di java.lang.Object, ordinandoli a piacere secondo una funzione di confronto da specificare. Quel che ne risulta è:

public class Database {
  private SortedSet s = Collections.synchronizedSortedSet(new TreeSet(new ComparaContatto()));
  public Database() {}
  ...

dove ComparaContatto è una classe avente un solo metodo che rispetta le specifiche di java.util.Comparator, relazionando fra loro due istanze di tipo Contatto sulla base dell'attributo nome. Essa si occupa dell'ordinamento fra gli oggetti contenuti nell'albero binario costituito dal SortedSet s.

Il completamento di Database non presenta difficoltà, perché l'interfaccia java.util.SortedSet presenta già le funzioni elencate nella specifica dell'ADT in esame. L'unica particolarità consiste nell'ammissione di "duplicati" da parte di un SortedSet: questo può essere facilmente evitato verificando la presenza di un certo individuo entro l'albero prima di modificarlo:

public synchronized void modifica(Contatto c) {
  s.remove(c);   // Prima si elimina l'eventuale record già presente...
  s.add(c);      // ...poi si reinserisce, evitando duplicati sulla base del nome.
}

Rispetto a quanto definito in fase di progetto, è conveniente incorporare entro Database anche un metodo che cancelli un record sulla base del nome: in questa maniera un'istanza della medesima classe può essere adottata per individuare anche il gruppo di discussione, soggetto ad ingressi ed uscite dei partecipanti.

Il lexer ed il parser per la manipolazione del file ASCII usato per assicurare la permanenza non presentano difficoltà: il corso di "Linguaggi e traduttori" la fa da maestro, consigliando un lexer che specializzi java.io.streamTokenizer per il corretto trattamento dei numeri interi (espressi anche in notazione esadecimale), mentre il parser realizza una classe a se stante, inglobando un'istanza privata di lexer tramite la quale agire sullo stream di char passato come parametro in fase di costruzione:

class Lexer extends StreamTokenizer {

 

  Lexer(Reader r) {
    super(r);
    resetSyntax();
    wordChars('a', 'z');
    wordChars('A', 'Z');
    wordChars('0', '9');
    wordChars('_', '_');
    wordChars('$', '$');
    commentChar(';');              // Carattere di inizio dei commenti sino a fine linea
    slashStarComments(true);       // Riconosciuti i commenti con sintassi C...
    slashSlashComments(true);      // ...e C++
    whitespaceChars('\t', '\t');   // I separatori fra le parole
    whitespaceChars('\r', '\r'); 
    whitespaceChars(' ', ' ');
    eolIsSignificant(false);       // 'End Of Line' come un blank
    ordinaryChar('/');             // 5 tipi di token specifici
    ordinaryChar(',');
    ordinaryChar('@');
    ordinaryChar(':');
    ordinaryChar('.');
  }

 

  public int nextToken() throws IOException { ...

 

 

 

class Parser {

 

  private Lexer lexer;

 

  Parser(Reader stream) { lexer = new Lexer(stream); }
  ...

In caso di errori sintattici/semantici è sufficiente generare una RuntimeException corredata di un messaggio esplicativo, il quale chiarisca il perché dell'eccezione. Questo causa la terminazione del programma, ma ciò non costituisce un problema visti gli obiettivi iniziali.

L'adozione delle due classi Lexer e Parser consente di aggiungere un ulteriore costruttore a Database, agevolandone l'uso pratico:

public Database(String nome_file) throws FileNotFoundException, IOException {
  InputStreamReader is = new InputStreamReader(new FileInputStream(nome_file));
  try {
    Parser p = new Parser(is);
    p.avanzaToken();
    while (p.tokenCorrente().length() > 0)
      modifica(p.prossimo());
  } finally { is.close(); }
}

Il protocollo di trasmissione resta individuato dall'ADT SocketUDPaffidabile, rispecchiato da un'omonima classe Java di tipo abstract dotata di:

Gli acknowledge adottati possono essere rappresentati da numeri int, sufficientemente capaci (32 bit) da evitare riavvolgimenti causa overflow per quest'applicativo software.

Un cenno in merito a sock: le DatagramSocket di Java non consentono la gestione a polling, quindi la cosa più simile che si possa realizzare è rendere sock "il meno bloccante possibile", invocando:

sock.setSoTimeout(1);

in fase di costruzione. Questo permette di intercettare la mancanza di datagrammi disponibili alla lettura mediante java.io.InterruptedIOException, restando bloccati sul socket al massimo per 1 millisecondo (forse…).

Gli algoritmi in pseudo-linguaggio illustrati in fase di progetto sono sufficientemente dettagliati da essere realizzati senza inconvenienti in Java. L'unico dettaglio riguarda i record contenuti nell'history delle conversazioni transitate attraverso sock:

class HistoryEntry {
  java.net.InetAddress IP;
  int port;
  int last_seq_num;

 

  HistoryEntry(java.net.InetAddress IP, int port, int last_seq_num) {
    this.IP = IP;
    this.port = port;
    this.last_seq_num = last_seq_num;
  }
}

risolve il problema, essendo visibile solo all'interno del package chat. Le istanze di questa classe saranno immagazzinate nel Vector history interno ad ogni oggetto SocketUDPaffidabile. Ereditando da quest'ultima classe si viene a costruire ciò che in fase di progetto è indicato col nome di "agente di controllo", qui implementato dalla classe Monitor già introdotta.

Per semplificare l'adozione della crittografia e della firma digitale da parte di un applicativo Java, è utile realizzare due semplici classi che permettono di applicare le suddette operazioni secondo uno stile funzionale: gli oggetti descritti dalla JCA sono atti a trattare un solo flusso informativo, perché al loro interno memorizzano tutti i dati, senza dare modo di azzerare questo buffer privato.

Per la firma digitale si può ricorrere a:

public class Firma {
  // L'algoritmo del java.security.Signature da impiegare.
  private String algoritmo;

 

  public Firma(String algoritmo) throws NoSuchAlgorithmException {
    Signature.getInstance(this.algoritmo = algoritmo);
  }

completandola con due funzioni di creazione della firma e di verifica della medesima, ove si crea un nuovo java.security.Signature per ciascuna invocazione:

  public byte[] firma(byte[] messaggio, int offset, int lun, PrivateKey chiave) {
    try {
      Signature sign = Signature.getInstance(algoritmo);
      sign.initSign(chiave);
      sign.update(messaggio, offset, lun);
      return sign.sign();
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
        throw new RuntimeException("Ora l'algoritmo non è più supportato?");
      }
      catch (InvalidKeyException e) { e.printStackTrace(); }
      catch (SignatureException e) { e.printStackTrace(); }
    return null;
  }

 

  public boolean firmaOk(byte[] messaggio, int offset, int lun, byte[] firma, PublicKey chiave) {
    try {
      Signature sign = Signature.getInstance(algoritmo);
      sign.initVerify(chiave); 
      sign.update(messaggio, offset, lun);
      return sign.verify(firma);
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
        throw new RuntimeException("Ora l'algoritmo non è più supportato?");
      }
      catch (InvalidKeyException e) { e.printStackTrace(); }
      catch (SignatureException e) { e.printStackTrace(); }
      return false;
  }

Il problema di risolvere è il medesimo nel caso della crittografia, quindi la soluzione non si discosta troppo da quella appena illustrata:

public class Crittografia {
  // L'algoritmo da usare nelle operazioni di cifratura e decifratura
  private String algoritmo;

 

  public Crittografia(String algoritmo) throws NoSuchAlgorithmException {
    Cipher.getInstance(this.algoritmo = algoritmo);
  }

 

  public byte[] cifra(byte[] dati, int offset, int lun, Key chiave) throws KeyException {
    try {
      Cipher c = Cipher.getInstance(algoritmo);
      c.initEncrypt(chiave);
      return c.doFinal(dati, offset, lun);
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
        throw new RuntimeException("Ora l'algoritmo non è più supportato?");
      }
  }

 

  public byte[] decifra(byte[] dati, int offset, int lun, Key chiave) throws KeyException {
    try {
      Cipher c = Cipher.getInstance(algoritmo);
      c.initDecrypt(chiave);
      return c.doFinal(dati, offset, lun);
    } catch (NoSuchAlgorithmException e) {
        e.printStackTrace();
        throw new RuntimeException("Ora l'algoritmo non è più supportato?");
      }
  }

Prima di passare al vero cuore del sistema si implementano i messaggi scambiati dall'applicativo.

Una public class MessaggioBase rappresenta un generico scambio di informazioni fra un mittente ed un destinatario, essendo composta da:

I tre campi vengono inizializzati in fase di costruzione da altrettanti parametri, mentre possono essere acceduti in lettura da tre omonimi selettori d'istanza public.

Dato che un messaggio è spesso destinato ad uno stream, si ricorre alla serializzazione di Java per assolvere a tale compito:

public class MessaggioBase implements java.io.Serializable {

La classe appena descritta può essere specializzata per ottenere un messaggio più conforme alle esigenze del problema, interpretando l'array di byte che_cosa secondo un preciso formato:

Dunque:

public class Messaggio extends MessaggioBase {

La "formattazione" del messaggio viene gestita dal costruttore:

public Messaggio(String da, String a, int seq_num, byte tipo, byte[] testo, byte[] firma) {
  super(da, a, new byte[4 + 4 + 1 + (testo==null?0:testo.length) + (firma==null?0:firma.length)]);
  scriviSeqNum(seq_num);   // Metodo d'istanza per scrivere il sequence number
  Utili.intToArray(firma == null ? 0 : firma.length, che_cosa, 4);   // Serializzazione di un int
  che_cosa[8] = tipo;
  if (testo != null)
    System.arraycopy(testo, 0, che_cosa, 4 + 4 + 1, testo.length);
  if (firma != null)
    System.arraycopy(firma, 0, che_cosa, che_cosa.length - firma.length, firma.length);
}

mentre opportuni metodi d'istanza fungono da selettori per accedere in lettura o scrittura ai singoli campi.

Questo "compattamento" di singoli campi entro un unico vettore di byte consente una facile cifratura dell'intero array, proteggendo tutti gli elementi che lo compongono da sguardi indiscreti.

Rimangono da definire Console e Monitor.

La prima classe è semplicissima, in quanto composta dal solo main:

public class Console {
  public static void main(String[] args) {
    ...
  }
}

All'interno di tale metodo essa deve:

Da notare che Console non possiede uno stato interno inerente la chat in corso, perché questo viene lasciato a Monitor; la classe appena descritta incarna un cosiddetto "terminale stupido", il quale si limita a fornire direttamente le risposte più semplici (la lettura della rubrica), demandando invece a Monitor le richieste che non è in grado di interpretare.

Per quanto concerne il Monitor si può affermare che esso realizza l'agente di controllo, venendo a costituire il lato "server" del sistema software.

Come stabilito, esso eredita da SocketUDPaffidabile, implementando i due metodi abstract che gestiscono i DatagramPacket provenienti e/o destinati alla rete; realizza inoltre il protocollo di dialogo, avvalendosi delle costanti che, per comodità, possono essere inglobate entro un'unica classe:

public class Protocollo {
  public static final byte SYN  = (byte) 0x80;
  public static final byte OK   = (byte) 0x40;
  public static final byte ADD  = (byte) 0x20;
  public static final byte TEXT = (byte) 0x10;
  public static final byte FIN  = (byte) 0x08;
}

La situazione è dunque la seguente:

public class Monitor extends SocketUDPaffidabile {
...

Si vuole incapsulare entro tale classe un gestore di chat, in modo da permettere esecuzioni di più istanze su una medesima stazione, associando ciascuna ad una porta UDP differente; di per sé questa caratteristica sembra superflua, ma non si vede il motivo per cui precluderla a priori, dato il minimo sforzo richiesto.

Entro Monitor si trova il metodo main, il quale si obbliga di avviare un'istanza della classe legandola, eventualmente, alla porta UDP specificata come primo parametro sulla linea di comando:

 

public static void main(String[] args) {
  Security.addProvider(new Cryptix());   // Installa dinamicamente Cryptix
  try {
    switch (args.length) {
      case 0: new Monitor().esegui();
              break;
      case 1: new Monitor(Integer.parseInt(args[0])).esegui();
              break;
      default: System.out.println("Sintassi d'uso:   java chat.Monitor [porta UDP]");
    }
  } catch (SocketException e) {
      e.printStackTrace();
    }
}

La prosecuzione del lavoro è deputata alla classe in esame, la quale contiene molteplici variabili di istanza di tipo private che vengono inizializzate in fase di costruzione, dovendo:

  1. invocare il costruttore della super-classe SocketUDPaffidabile;
  2. aprire in lettura il file di scambio con Console:
    swap = new BufferedReader(new FileReader("swap.txt"));
  3. istanziare la rubrica partendo dal file ASCII persistente su memoria di massa:
    rubrica = new Database("Database.txt");
  4. leggere il nickname dello user come primo dato da suddetto file:
    nickname = swap.readLine();
  5. recuperare le due chiavi RSA dell'utente:
    c_pubblica = new RawRSAPublicKey(new FileInputStream(nickname + ".pub"));
    c_privata  = new RawRSAPrivateKey(new FileInputStream(nickname + ".pri"));
  6. allocare gli oggetti crittografici:
    md5rsa = new Firma("MD5/RSA");
    rsa    = new Crittografia("RSA/ECB/PKCS#7");
    idea   = new Crittografia("IDEA/ECB/PKCS#5");
  7. inizializzare il generatore della chiave di sessione:
    generatore_c = KeyGenerator.getInstance("IDEA");
    generatore_c.initialize(entropia);

    dove entropia è un'istanza di java.security.SecureRandom;

  8. cancellare, per sicurezza, il nickname dell'utente da rubrica, evitando invii a se stessa:
    rubrica.cancella(nickname);

Qualche messaggio diagnostico risulta utile per verificare il corretto funzionamento dell'applicativo.

I due metodi seguenti implementano concretamente quanto richiesto dalla super-classe:

protected int inserisciSeqNum(DatagramPacket d) {
  Messaggio m = messaggioFromDatagram(d);
  seq_num += entropia.nextInt(0xffff) + 1;   // Il contatore pei sequence number
  if (ACCLUDI_FIRMA_DIGITALE)
    m = new Messaggio(m.daChi(), m.aChi(), seq_num, m.tipo(), m.testo(), md5rsa.firma(m.testo(), c_privata));
  else
    m.scriviSeqNum(seq_num);
  if (ADOTTA_CRITTOGRAFIA)
    try {
      m = new Messaggio(m.daChi(), m.aChi(),
      interlocutori.contiene(m.aChi()) ? idea.cifra(m.cheCosa(), c_sessione)
                                       : rsa.cifra(m.cheCosa(), rubrica.seleziona(m.aChi()).chiave()));
    } catch (KeyException e) {
        e.printStackTrace();
      }
  messaggioToDatagram(m, d);
  return seq_num;
}

 

protected int estraiSeqNum(DatagramPacket d) {
  Messaggio m = messaggioFromDatagram(d);
  if (ADOTTA_CRITTOGRAFIA)
    try {
      m = new Messaggio(m.daChi(), m.aChi(),
      interlocutori.contiene(m.daChi()) ? idea.decifra(m.cheCosa(), c_sessione)
                                        : rsa.decifra(m.cheCosa(), c_privata));
      messaggioToDatagram(m, d);
    } catch (KeyException e) {
        e.printStackTrace();
      }
  return m.seqNum();
}

dove ACCLUDI_FIRMA_DIGITALE e ADOTTA_CRITTOGRAFIA sono costanti private di classe usate come flag per rendere più versatile il sistema, escludendo eventualmente la firma digitale e il supporto crittografico.

Il solo metodo public della classe è esegui(), il quale si occupa di realizzare il loop principale:

Le due azioni precedenti vengono ripetute fintanto che dal file di scambio viene letta una linea eguale ad uno dei quattro comandi che determinano la fine dell'applicazione.

 

CODIFICA

Di seguito l'esito della codifica:

NOME DEL FILE AZIONE SVOLTA DAL FILE
Endpoint.java Implementazione di un endpoint di rete.
Contatto.java Astrazione di un utilizzatore del sistema software.
Lexer.java Lexer per l'analisi lessicale del file ASCII usato come rubrica persistente.
Parser.java Parser per l'analisi sintattica del file ASCII usato come rubrica persistente.
Database.java Rubrica di tutti i Contatti.
MessaggioBase.java Generico messaggio scambiato fra due utenti.
Messaggio.java Messaggio specifico per le esigenze dell'applicativo in questione.
Firma.java Classe per semplificare l'impiego della firma digitale.
Crittografia.java Classe per semplificare l'impiego della crittografia.
SocketUDPaffidabile.java Realizzazione (abstract) di un socket UDP reliable.
Protocollo.java Insieme delle costanti definite dal protocollo di dialogo.
Console.java Il "lato client" del sistema, usato come terminale di input.
Monitor.java Il "lato server" del sistema, usato come terminale di output.
Utili.java Funzioni di utilità generali.
CreaChiaviRSA.java Programma stand alone per creare delle coppie di chiavi RSA.
Cronometro.java Utility per misurare intervalli di tempo.

 

TESTING

La fase di test ha proceduto correttamente, senza inconvenienti considerevoli.

Le prestazioni crittografiche non sono così "drammatiche" come ci si poteva aspettare, fornendo un'interattività sufficientemente buona da rendere il prodotto sicuramente accettabile. Anche la firma digitale richiede pochi millesimi di secondo, facendo sì che la scalabilità complessiva sia discreta.

Particolare degno di nota è il funzionamento delle java.net.DatagramSocket: nonostante la dimensione massima di un datagramma UDP sia di 64Kbyte, il messaggio più lungo che si è riusciti a scambiare si attesta sui 12Kbyte circa, adottando il JDK versione 1.2.2 su piattaforma Windows 98. Impossibile porre rimedio al problema, anche agendo sui buffer di invio/ricezione tramite i metodi setReceiveBufferSize(int) e setSendBufferSize(int) appartenenti ad un oggetto di tipo java.net.DatagramSocket.

Un problema riscontrato, peraltro non imputabile al progetto in esame, risiede in un certo overhead introdotto dalla libreria Cryptix 3.2.0 quando genera la chiave di sessione per la prima volta: prove sperimentali hanno dimostrato che tale package impiega venti/trenta secondi per creare la prima chiave di sessione, dopodiché le chiavi successive richiedono pochi decimi di secondo. Apparentemente inspiegabile la causa di ciò, potrebbe essere il caso di cercare entro il codice sorgente del package stesso il motivo di questo avvio assai lento. Improbabile che la tecnologia JIT possa produrre benefici così estremi.

A seguito dei test condotti è emerso un particolare interessante: l'implementazione dell'algoritmo RSA inclusa entro Cryptix 3.2.0 è "parziale", nel senso che obbliga ad utilizzare chiavi pubbliche in fase di cifratura e chiavi private all'atto della decifratura. Questo non fa parte dell'algoritmo stesso, perché secondo le sue specifiche le due chiavi sono totalmente intercambiabili. E' immediato verificarlo anche osservando la dimostrazione matematica dell'algoritmo stesso!

La cosa lascia certamente perplessi; non è sembrato il caso di disturbare gli autori della libreria in merito alla questione.

Il codice prodotto è stato realizzato con i commenti opportunamente formattati per venire elaborati dal tool javadoc fornito col JDK della SUN®; la documentazione così ottenuta è ispezionabile seguendo questo link.

 

CONCLUSIONI

Il sistema software complessivo appare accettabile, pur mantenendo certi aspetti un poco "rudi" per quanto concerne la gestione del database e l'interfaccia fornita. D'altronde questo rientra nelle decisioni prese in fase di lavorazione.

Il protocollo crittografico adottato risulta sufficientemente sicuro: gli unici attacchi cui è soggetto sono quelli di forza bruta, come tutti i messaggi cifrati, ed i replay attack relativi al messaggio di SYN. Motivando questo secondo problema, la cosa sta a significare che un intruso dotato di sniffer, consapevole che il primo datagramma inviato da un utente è certamente un SYN, può registrare tale informazione per poi riproporla in un secondo tempo al destinatario del messaggio stesso.

Questo non è un problema, perché la risposta ad un SYN è cifrata con la chiave RSA pubblica del ricevente, e solo quest'ultimo è in grado di estrarre la chiave di sessione contenuta nel datagramma. Il solo inconveniente è che il gruppo di discussione crede di aver introdotto un nuovo membro quando invece questi ignora completamente la situazione!

In un'ottica di "prodotto atto all'uso" è consigliabile anche limitare la scalabilità del sistema, ponendo un limite massimo al numero di membri appartenenti ad un gruppo di discussione. La cosa non presenta difficoltà, vista l'architettura adottata.

Un possibile miglioramento consiste nell'adozione di directed broadcast per l'invio dei messaggi di SYN: questo obbliga a non utilizzare ARQ per tali datagrammi, perché a fronte di un unico invio sono prevedibili molteplici ricezioni, quindi altrettanti acknowledge, peraltro non significativi perché solo il destinatario cercato è in grado di risalire al numero di sequenza corretto del segmento ricevuto, essendo protetto da cifratura RSA.

A tal fine è presente la funzione directedBroadcast(java.net.InetAddress) entro il file Utili.java, anche se non viene usata in questa versione dell'applicativo.

Da evidenziare il mancato uso dei thread per assicurare la portabilità su JVM con scheduler not-preemptive (vedi Solaris).