Criando um stream seguro usando criptografia assimétrica/simétrica

watch_later 11 de mar de 2014
Um grade problema - segurança e confiabilidade

Muitas vezes, eu criei programas baseados para gerenciamento de websites onde muitos usuários tinha acesso e até mesmo se comunicavam um com o outro, mas sem o uso de qualquer segurança especial para eles. O problema apareceu quando eu criei um programa de bate-papo e queria enviar login / senha e também evitar que alguém tivesse acesso ao servidor para ver informações ou mesmo as conversas.
Eu costumava usar SSL , que realmente funciona bem,  mas a aquisição dos certificados é o problema.

A Solução


Bem , eu logo procurei a solução mais barata (não usar os certificados). Eu sabia que o SSL usa uma chave assimétrica para se conectar, e depois cria uma chave simétrica para continuar sua comunicação .

Foi então que criei uma solução que funciona , 
Durante a inicialização, o servidor cria uma chave assimétrica e envia a parte pública para o cliente. O cliente , então, cria uma chave simétrica e criptografa usando a chave pública do servidor (assim , apenas o servidor sabe como decifrá-lo ) . Ele envia a chave para o servidor e, em seguida , só que desta vez chave simétrica que é usada.

Vamos ao código:

public SecureStream(Stream baseStream, RSACryptoServiceProvider rsa, 
 SymmetricAlgorithm symmetricAlgorithm, bool runAsServer)
{
  if (baseStream == null)
    throw new ArgumentNullException("baseStream");
  
  if (rsa == null)
    throw new ArgumentNullException("rsa");
    
  if (symmetricAlgorithm == null)
    throw new ArgumentNullException("symmetricAlgorithm");

  BaseStream = baseStream;
  SymmetricAlgorithm = symmetricAlgorithm;

  string symmetricTypeName = symmetricAlgorithm.GetType().ToString();
  byte[] symmetricTypeBytes = Encoding.UTF8.GetBytes(symmetricTypeName);
  if (runAsServer)
  {
    byte[] sizeBytes = BitConverter.GetBytes(symmetricTypeBytes.Length);
    baseStream.Write(sizeBytes, 0, sizeBytes.Length);
    baseStream.Write(symmetricTypeBytes, 0, symmetricTypeBytes.Length);
  
    byte[] bytes = rsa.ExportCspBlob(false);
    sizeBytes = BitConverter.GetBytes(bytes.Length);
    baseStream.Write(sizeBytes, 0, sizeBytes.Length);
    baseStream.Write(bytes, 0, bytes.Length);
    
    symmetricAlgorithm.Key = p_ReadWithLength(rsa);;
    symmetricAlgorithm.IV = p_ReadWithLength(rsa);
  }
  else
  {
    
    var sizeBytes = new byte[4];
    p_FullReadDirect(sizeBytes);
    var stringLength = BitConverter.ToInt32(sizeBytes, 0);
    
    if (stringLength != symmetricTypeBytes.Length)
      throw new ArgumentException
 ("Server and client must use the same SymmetricAlgorithm class.");
    
    var stringBytes = new byte[stringLength];
    p_FullReadDirect(stringBytes);
    var str = Encoding.UTF8.GetString(stringBytes);
    if (str != symmetricTypeName)
      throw new ArgumentException
 ("Server and client must use the same SymmetricAlgorithm class.");

    sizeBytes = new byte[4];
    p_FullReadDirect(sizeBytes);
    int asymmetricKeyLength = BitConverter.ToInt32(sizeBytes, 0);
    byte[] bytes = new byte[asymmetricKeyLength];
    p_FullReadDirect(bytes);
    rsa.ImportCspBlob(bytes);
    
    p_WriteWithLength(rsa, symmetricAlgorithm.Key);
    p_WriteWithLength(rsa, symmetricAlgorithm.IV);
  }
      
  rsa.Clear();
  
  Decryptor = symmetricAlgorithm.CreateDecryptor();
  Encryptor = symmetricAlgorithm.CreateEncryptor();
  
  fReadBuffer = new byte[0];
  fWriteBuffer = new MemoryStream(32 * 1024);
}

O construtor é grande, mas vou explicar as partes fundamentais. Ele foi criado para ser capaz de receber uma RSA algoritmo assimétrico já criado e inicializado e algoritmo assimétrico de escolha dos usuários. Após a verificação de argumentos nulos e definindo as propriedades BaseStream e SymmetricAlgorithm , ele deve decidir se ele será executado como um servidor ou como um cliente.

O Servidor

O servidor envia o nome do algoritmo simétrico a ser utilizado, que será usado pelo cliente para verificar a compatibilidade, exporta a chave pública RSA gerada e também envia para o cliente e, finalmente, lê a chave e o vetor de inicialização simétrica que vai ser enviada pelo cliente.


O Cliente

 O cliente efetua o processo inverso. Assim, em primeiro lugar recebe o chamado algoritmo usado pelo servidor, se o comprimento do nome do algoritmo ou o próprio nome não coincidirem, ele lança uma exceção. depois, ele recebe a chave RSA utilizada pelo servidor e envia a chave e o vetor de inicialização de seu algoritmo simétrico. 

Eu não mostrou a criptografia com a chave RSA, mas é feito pelo p_ReadWithLength e p_WriteWithLength, que vou mostrar depois. Só para terminar o construtor, ele limpa a chave RSA, cria o codificador simétrica e decoder e inicializa os buffers.

Vamos ver a criptografia assimétrica:

private byte[] p_ReadWithLength(RSACryptoServiceProvider rsa)
{
  byte[] size = new byte[4];
  p_FullReadDirect(size);

  int count = BitConverter.ToInt32(size, 0);
  var bytes = new byte[count];
  int read = 0;
  while(read < count)
  {
    int readResult = BaseStream.Read(bytes, read, count - read);
    if (readResult == 0)
      throw new IOException("The stream was closed by the remote side.");
    
    read += readResult;
  }
  
  return rsa.Decrypt(bytes, false);
}
private void p_WriteWithLength(RSACryptoServiceProvider rsa, byte[] bytes)
{
  bytes = rsa.Encrypt(bytes, false);
  byte[] sizeBytes = BitConverter.GetBytes(bytes.Length);
  BaseStream.Write(sizeBytes, 0, sizeBytes.Length);
  BaseStream.Write(bytes, 0, bytes.Length);
}

O Write criptografa a mensagem e envia o tamanho da mensagem criptografada e a própria mensagem. 

O Read lê o tamanho, em seguida, lê a mensagem e terminar de descodificar e retorna a mensagem descodificada. Mas, espere, por que eu uso "BaseStream.Write" e p_FullReadDirect? Por que não BaseStream.Read? 

Estou realmente pensando em fazer isso em um método de extensão. Se você olhar como ler e escrever trabalhos, você vai notar a diferença. Escreva simplesmente escreve todos os buffers solicitados ou lança uma exceção. Ler é mais problemática, como você pode pedir para 1024 bytes, e ele retorna 3, porque ele lê apenas 3 bytes. Mas eu não espero que isso aconteça, quero que o bloco completo, mesmo que eu preciso chamar ler muitas vezes. Então, p_FullReadDirect parecido com este:

private void p_FullReadDirect(byte[] bytes, int length)
{
  int read = 0;
  while(read < length)
  {
    int readResult = BaseStream.Read(bytes, read, length - read);
    
    if (readResult == 0)
      throw new IOException("The stream was closed by the remote side.");
    
    read += readResult;
  }
}

Ok. A inicialização é feita. Neste momento, podemos dizer que temos o construtor totalmente implementado, já usou o algoritmo RSA (assimétrica) e agora só precisa se preocupar com o verdadeiro streaming. 

Então, vamos primeiro entender o Encryptor e Decryptor. Pelo menos, a parte que eu entendi: 
O Encryptor e Decryptor são ICryptoTransform. Nele, temos a TransformBlock e TransformFinalBlock. Eu realmente considerado usando TransformBlock, mas em meus testes, eu criptografar um bloco e tentar decifrá-lo, e nada acontece. Se eu juntar os blocos e, ao TransformFinalBlock chamada final, eu recebo o resultado errado, então eu decidi usar apenas TransformFinalBlock, que só funciona bem. 

O problema é que em cada "criptografia final", eu posso acabar-se com um tamanho extra na mensagem. Assim, em vez de criptografar cada gravação, eu tamponar todos eles em um fluxo de memória e, durante Flush, eu criptografar todas as gravações e enviá-los. 

Assim:

public override void Write(byte[] buffer, int offset, int count)
{
  fWriteBuffer.Write(buffer, offset, count);
}
public override void Flush()
{
  if (fWriteBuffer.Length > 0)
  {
    var encryptedBuffer = Encryptor.TransformFinalBlock
  (fWriteBuffer.GetBuffer(), 0, (int)fWriteBuffer.Length);
    var size = BitConverter.GetBytes(encryptedBuffer.Length);
    BaseStream.Write(size, 0, size.Length);
    BaseStream.Write(encryptedBuffer, 0, encryptedBuffer.Length);
    BaseStream.Flush();
    
    fWriteBuffer.SetLength(0);
    fWriteBuffer.Capacity = 32 * 1024;
  }
}

Eu sempre repor a capacidade do buffer de 32K, como em um único momento em que pode enviar 1MB de informação, mas, depois, continuamos com 32KB. Eu poderia fazê-lo configurável, mas isso não vai mudar a verdadeira coisa importante aqui: 
Eu primeiro grupo todos os tampões, o que não é o problema. Mas, quando eu enviá-lo, eu também deve enviar o tamanho criptografado, como para descriptografar com TransformFinalBlock precisamos do bloco inteiro para ser carregado. A descriptografia em si não é muito complicado, mas o método de leitura é. 

Por quê? 
Porque o lado remoto envia 1MB de dados. Para decifrar, devo ler a 1 MB de dados e descriptografar. Mas, o código de chamada apenas quer ler quatro bytes. Eu não posso simplesmente descartar o resto do tampão, devo ler a parte do buffer solicitado e atualizar a posição interna, para a próxima leitura pode ler outra parte do buffer. Além disso, podemos ter um buffer de 16 bytes e uma leitura de 1024 bytes, mas, neste caso, usamos o comportamento Leitura do retorno que um 16 foi lido. 

Então, vamos ver:

public override int Read(byte[] buffer, int offset, int count)
{
  if (fReadPosition == fReadBuffer.Length)
  {
    p_ReadDirect(fSizeBytes);
    int readLength = BitConverter.ToInt32(fSizeBytes, 0);
    
    if (fReadBuffer.Length < readLength)
      fReadBuffer = new byte[readLength];
      
    p_FullReadDirect(fReadBuffer, readLength);
    fReadBuffer = Decryptor.TransformFinalBlock(fReadBuffer, 0, readLength);
    
    fReadPosition = 0;
  }
  
  int diff = fReadBuffer.Length - fReadPosition;
  if (count > diff)
    count = diff;
  
  Buffer.BlockCopy(fReadBuffer, fReadPosition, buffer, offset, count);
  fReadPosition += count;
  return count;
}

Quando fazemos a primeira leitura, estamos na posição 0, eo readbuffer tem tamanho 0, assim que entramos no caso. Vamos ler o tamanho da mensagem, crie uma nova readbuffer se a nossa não tem comprimento suficiente e, em seguida, irá "fullread" o messagesize. Com isso, temos o buffer criptografado completo, por isso decifrá-lo à variável readbuffer e dizer que estamos no início do mesmo. 

Lefting bloco if, vamos executar verificação se a leitura quer ler mais bytes do que ainda temos. Se for esse o caso, reduzir a variável de contagem. Então, nós simplesmente "BlockCopy" o bloco que queremos, atualizar o ReadPosition e retornar o número de bytes lidos (a contagem). 

Bem, é isso. Se lermos "byte a byte", vamos continuar incrementando ReadPosition enquanto ainda temos um buffer válido na memória. Assim que termina, que vai receber a próxima. E isso é tudo. O fluxo já está funcionando.