Encrypt with 'window.crypto.subtle', decrypt in c#

664 views Asked by At

I want to encrypt with window.crypto.subtle and decrypt in C#.

The crypt / decrypt in js are working.

In C#, The computed authentification tag don't match the input.

I don't know if I can put any 12 bytes as salt nor if I need to derive the password.

export async function deriveKey(password, salt) {
  const buffer = utf8Encoder.encode(password);
  const key = await crypto.subtle.importKey(
    'raw',
    buffer,
    { name: 'PBKDF2' },
    false,
    ['deriveKey'],
  );

  const privateKey = crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      hash: { name: 'SHA-256' },
      iterations,
      salt,
    },
    key,
    {
      name: 'AES-GCM',
      length: 256,
    },
    false,
    ['encrypt', 'decrypt'],
  );

  return privateKey;
}
const buff_to_base64 = (buff) => btoa(String.fromCharCode.apply(null, buff));
const base64_to_buf = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(null));

export async function encrypt(key, data) {
  const salt = crypto.getRandomValues(new Uint8Array(12));
  const iv = crypto.getRandomValues(new Uint8Array(12));

  console.log('encrypt');
  console.log('iv', iv);
  console.log('salt', salt);

  const buffer = new TextEncoder().encode(data);

  const privatekey = await deriveKey(key, salt);

  const encrypted = await crypto.subtle.encrypt(
    {
      name: 'AES-GCM',
      iv,
      tagLength: 128,
    },
    privatekey,
    buffer,
  );

  const bytes = new Uint8Array(encrypted);
  console.log('concat');

  const buff = new Uint8Array(iv.byteLength + encrypted.byteLength + salt.byteLength);
  buff.set(iv, 0);
  buff.set(salt, iv.byteLength);
  buff.set(bytes, iv.byteLength + salt.byteLength);

  console.log('iv', iv);
  console.log('salt', salt);
  console.log('buff', buff);

  const base64Buff = buff_to_base64(buff);
  console.log(base64Buff);
  return base64Buff;
}

export async function decrypt(key, data) {
  console.log('decryption');
  console.log('buff', base64_to_buf(data));

  const d = base64_to_buf(data);
  const iv = d.slice(0, 12);
  const salt = d.slice(12, 24);
  const ec = d.slice(24);

  console.log('iv', iv);
  console.log('salt', salt);
  console.log(ec);

  const decrypted = await window.crypto.subtle.decrypt(
    {
      name: 'AES-GCM',
      iv,
      tagLength: 128,
    },
    await deriveKey(key, salt),
    ec,
  );

  return new TextDecoder().decode(new Uint8Array(decrypted));
}
Span<byte> encryptedData = Convert.FromBase64String(enc).AsSpan();
Span<byte> nonce = encryptedData[..12];
Span<byte> salt = encryptedData.Slice(12, 12);
Span<byte> data = encryptedData.Slice(12 + 12, encryptedData.Length - 16 - 12 - 12);
Span<byte> tag = encryptedData[^16..];

Span<byte> result = new byte[data.Length];

using Rfc2898DeriveBytes pbkdf2 = new(Encoding.UTF8.GetBytes(password), salt.ToArray(), 1000, HashAlgorithmName.SHA256);
using AesGcm aes = new(pbkdf2.GetBytes(16));

aes.Decrypt(nonce, data, tag, result);
1

There are 1 answers

1
Topaco On BEST ANSWER

There are a few inconsistencies and/or minor flaws in both codes. Concerning the JavaScript code:

  • The salt should be concatenated like the IV along with the ciphertext/tag (ciphertext/tag = implicit concatenation of the actual ciphertext and tag), e.g. salt|IV|ciphertext|tag. The IV should be randomly generated like the salt.
  • In both codes the same iteration count must be used for key derivation with PBKDF2, e.g. 25000 (in practice the value should be set as high as possible while maintaining acceptable performance).
  • In both codes, the PBKDF2 key derivation must generate AES keys of the same length, so that the same AES variant is used, e.g. a 32 bytes key for AES-256.

With these changes, the JavaScript code

(async () => {

    const utf8Encoder = new TextEncoder('utf-8');
    const salt = crypto.getRandomValues(new Uint8Array(16)); // Fix 1: consider salt
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const iterations = 25000; // Fix 2: apply the same iteration count

    async function deriveKey(password) {
        const buffer = utf8Encoder.encode(password);
        const key = await crypto.subtle.importKey(
            'raw',
            buffer,
            { name: 'PBKDF2' },
            false,
            ['deriveKey'],
        );

        const privateKey = crypto.subtle.deriveKey(
            {
                name: 'PBKDF2',
                hash: { name: 'SHA-256' },
                iterations,
                salt,
            },
            key,
            {
                name: 'AES-GCM',
                length: 256, // Fix 3: use the same key size
            },
            false,
            ['encrypt', 'decrypt'],
        );

        return privateKey;
    }
    
    const buff_to_base64 = (buff) => btoa(String.fromCharCode.apply(null, buff));
    const base64_to_buf = (b64) => Uint8Array.from(atob(b64), (c) => c.charCodeAt(null));

    async function encrypt(key, data, iv, salt) {
        const buffer = new TextEncoder().encode(data);

        const privatekey = await deriveKey(key);
        const encrypted = await crypto.subtle.encrypt(
            {
                name: 'AES-GCM',
                iv,
                tagLength: 128,
            },
            privatekey,
            buffer,
        );

        const bytes = new Uint8Array(encrypted);
        let buff = new Uint8Array(salt.byteLength + iv.byteLength + encrypted.byteLength);
        buff.set(salt, 0); // Fix 1: consider salt
        buff.set(iv, salt.byteLength);
        buff.set(bytes, salt.byteLength + iv.byteLength);

        const base64Buff = buff_to_base64(buff);
        return base64Buff;
    }

    async function decrypt(key, data) {
        const d = base64_to_buf(data);
        const salt = d.slice(0, 16); // Fix 1: consider salt
        const iv = d.slice(16, 16 + 12)
        const ec = d.slice(16 + 12);

        const decrypted = await window.crypto.subtle.decrypt(
            {
                name: 'AES-GCM',
                iv,
                tagLength: 128,
            },
            await deriveKey(key),
            ec
        );

        return new TextDecoder().decode(new Uint8Array(decrypted));
    }

    var data = 'The quick brown fox jumps over the lazy dog';
    var passphrase = 'my passphrase';
    var ct = await encrypt(passphrase, data, iv, salt);
    var dt = await decrypt(passphrase, ct);
    console.log(ct);
    console.log(dt);

})();

returns, e.g.:

P/y3nrZU70XtanEUvubyVUp+LzOVHLGAl55cd+N6T0c9ak15KVXh5UxFEjMYGsvGWzf286wAGc5HgEjmwxWCkdjSt5vt42Anb4jwKlVMdLyYoP9Gg/be

In the C# code, salt, IV, and ciphertext/tag must be correctly separated, and keysize and iteration count of the JavaScript code must be used:

string ciphertext = "P/y3nrZU70XtanEUvubyVUp+LzOVHLGAl55cd+N6T0c9ak15KVXh5UxFEjMYGsvGWzf286wAGc5HgEjmwxWCkdjSt5vt42Anb4jwKlVMdLyYoP9Gg/be";
Span<byte> encryptedData = Convert.FromBase64String(ciphertext).AsSpan();
Span<byte> salt = encryptedData[..16]; // Fix 1: consider salt (and apply the correct parameters)
Span<byte> nonce = encryptedData[16..(16 + 12)];  
Span<byte> data = encryptedData[(16 + 12)..^16]; 
Span<byte> tag = encryptedData[^16..];

string password = "my passphrase";
using Rfc2898DeriveBytes pbkdf2 = new(Encoding.UTF8.GetBytes(password), salt.ToArray(), 25000, HashAlgorithmName.SHA256); // Fix 2: apply the same iteration count

using AesGcm aes = new(pbkdf2.GetBytes(32)); // Fix 3: use the same key size (e.g. 32 bytes for AES-256)
Span<byte> result = new byte[data.Length];
aes.Decrypt(nonce, data, tag, result);

Console.WriteLine(Encoding.UTF8.GetString(result)); // The quick brown fox jumps over the lazy dog

Then the ciphertext of the JavaScript code can be successfully decrypted with the C# code.