Abe Estrada

Privnote

Hace tiempo que tengo que lidiar con la estrategia de compartir información de manera segura a terceros, como por ejemplo contraseñas. Hasta ahora el método es enviar el nombre de usuario por un método como correo electrónico o chat, y la contraseña por otro medio totalmente distinto o de encriptado, para poder separar la información y que sea más complicado obtener en caso de que la seguridad del medio por él que fue compartida sea comprometido.

Recuerdo que en algún lugar leí que recomendaban utilizar un servicio como Privnote para encriptar la información y compartir un enlace que es eliminado una vez que es leído, al puro estilo de Misión Imposible. Pero este tipo de servicios me causa conflicto, sobre todo los de código cerrado como en este caso, por que no tengo control de lo que sucede en el lado del servidor, aunque su código sea abierto, no se puede estar seguro que la información esta 100% protegida.

Esto me llevó a buscar alguna alternativa y no encontré algo que me convenciera totalmente, por lo que tuve la oportunidad de aplicar una idea que llevaba tiempo pensando en como aplicarla. La idea es sencilla, es poder tener un servicio web con frontend y backend dentro de una función Lambda de AWS. Esto quiere decir que todo el código html y en mi caso de JavaScript debe estar dentro de la función sin ningún servicio de hospedaje extra. Todo esto llevaba tiempo ideando la forma de hacerlo, para no tener que depender de un servidor y no tener que crear un S3 bucket, ni crear una instacia de CloudFront para nada, simple y sencillamente una función que haga todo.

Así que me dí a la tarea de clonar el servicio de Privnote dentro de una función nada más.

La idea es tener tres vistas.

  • GET / Con la página inicial y la pequeña forma para crear una nota privada.
  • POST / Que recibe la información, la guarda y muestra al usuario como recuperarla
  • GET /:uuid Muestra al usuario la información requerida
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";

const s3Client = new S3Client({ region: "us-east-1" });

const Bucket = "notasprivadas";

export const handler = async (event) => {
  let statusCode = 200;
  let body = ``;
  if (event.requestContext.http.method === "POST") {
    // POST /
    const buff = Buffer.from(event.body ?? "", "base64");
    const eventBody = buff.toString("UTF-8");
    const params = new URLSearchParams(eventBody);
    const uuid = params.get("uuid");
    const note = params.get("ciphered");
    const bytes = Buffer.byteLength(note);
    // Only allow to save notes below 1MB
    if (bytes < 1048577) {
      const bucketParams = {
        Bucket,
        ACL: "private",
        ServerSideEncryption: "AES256",
        // Do not store the last part of the key
        Key: uuid?.split("-")?.splice(0, 4)?.join("-"),
        Body: `${note}`,
      };
      try {
        // Save note
        await s3Client.send(new PutObjectCommand(bucketParams));
        body = `<div>
  <p>
    <label for="note" class="w-100 alert alert-warning fw-light fst-italic py-1">The note will self-destruct after reading it.</label>
    <input type="text" class="form-control shadow-sm" id="link" value="https://biubkhqe6zz3hypoitljy2vqti0yyvbn.lambda-url.us-east-1.on.aws/${uuid}" onClick="this.select()" readonly />
  </p>
</div>`;
      } catch (error) {
        console.log(error);
        statusCode = 500;
        body = `<div class="alert alert-danger">Error: saving</div>`;
      }
    } else {
      statusCode = 500;
      body = `<div class="alert alert-danger">Error: too big</div>`;
    }
  } else {
    // GET /:uuid
    const uuid = `${event.requestContext.http.path}`.replaceAll("/", "");
    if (uuid !== "") {
      const bucketParams = { Bucket, Key: uuid?.split("-")?.splice(0, 4)?.join("-") };
      try {
        // Load note
        const data = await s3Client.send(new GetObjectCommand(bucketParams));
        const streamToString = await data.Body?.transformToString();
        try {
          // Delete note
          await s3Client.send(new DeleteObjectCommand(bucketParams));
          // Show note to the user
          body = `<form id="noteForm">
  <div class="col-12">
    <label for="note" class="w-100 alert alert-warning fw-light fst-italic py-1">This note was destroyed. If you need to keep it, copy it before closing this window.</label>
    <textarea class="form-control shadow-sm" id="note" name="note" rows="10" required></textarea>
  </div>
</form>
<script type="text/javascript" src="https://unpkg.com/crypto-js/crypto-js.js"></script>
<script type="text/javascript">
  const formEl = document.getElementById("noteForm");
  document.addEventListener("DOMContentLoaded", () => {
    // Decrypt in the browser
    const bytes  = CryptoJS.AES.decrypt("${streamToString}", "${uuid}");
    const originalText = bytes.toString(CryptoJS.enc.Utf8);
    formEl.elements.note.value = originalText;
  });
</script>`;
        } catch (error) {
          console.log(error);
          statusCode = 500;
          body = `<div class="alert alert-danger">Error: deleting</div>`;
        }
      } catch (error) {
        console.log(error);
        statusCode = 404;
        body = `<div class="alert alert-danger">Error 404: not found</div>`;
      }
    } else {
      // Default GET /
      body = `<form id="noteForm" class="row g-3 mx-auto" method="POST">
  <div class="col-12">
    <label for="note" class="form-label">New note:</label>
    <textarea class="form-control shadow-sm" id="note" name="note" rows="10" required></textarea>
  </div>
  <div class="col-12 mt-4">
    <button type="submit" name="submitButton" class="btn btn-primary shadow">Create note</button>
  </div>
</form>
<script type="text/javascript" src="https://unpkg.com/crypto-js/crypto-js.js"></script>
<script type="text/javascript">
  const formEl = document.getElementById("noteForm");
  formEl.addEventListener("submit", (event) => {
    formEl.elements.submitButton.disabled = true;
    event.preventDefault();
    // Generate uuid in the browser
    const uuid = crypto.randomUUID();
    // Cipher text
    const ciphertext = CryptoJS.AES.encrypt(formEl.elements.note.value, uuid).toString();
    // Add a hidden input to the form with the ciphered text
    const cipheredEl = document.createElement('input');
    cipheredEl.type = "hidden";
    cipheredEl.name = "ciphered";
    cipheredEl.value = ciphertext;
    formEl.appendChild(cipheredEl);
    // Add a hidden input with the uuid
    const uuidEl = document.createElement('input');
    uuidEl.type = "hidden";
    uuidEl.name = "uuid";
    uuidEl.value = uuid;
    formEl.appendChild(uuidEl);
    // Submit form
    formEl.submit();
  });
</script>`;
    }
  }
  const html = `<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Private Note</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous" />
  </head>
  <body class="bg-body-tertiary">
    <nav class="navbar navbar-dark bg-dark">
      <div class="container-fluid" style="max-width:720px">
        <a class="navbar-brand" href="/">🔒 Private Note</a>
      </div>
    </nav>
    <main class="container my-3" style="max-width:720px">
      ${body}
    </main>
  </body>
</html>`;
  const response = {
    statusCode,
    headers: { "Content-Type": "text/html" },
    body: html,
  };
  return response;
};

Los permisos necesarios para interactuar con el “bucket”, son los siguientes:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:PutObjectAcl", "s3:GetObject", "s3:GetObjectAcl", "s3:DeleteObject"],
      "Resource": ["arn:aws:s3:::notasprivadas", "arn:aws:s3:::notasprivadas/*"]
    }
  ]
}

La función devuelve solo html, en la primer vista solo se muestra una caja de texto y un botón, al enviar la forma, encripta el texto en el navegador y crea una “llave” que vamos a usar como url, luego se envía a la misma función (POST) y es recibida, se guarda el texto encriptado en un “bucket” de S3 con una parte de la llave, y se le muestra la url completa al usuario con la llave para desencriptar el texto, esto hace que la información guardada no pueda ser desencriptada sin la llave completa que solamente tiene el usuario. Al entrar a la url con la llave, se busca el archivo guardado y se envía el texto encriptado al usuario para que sea desencriptado en el navegador con la llave completa.

El navegador genera un uuid al azar que consta de 5 partes y solo utilizamos 4, el usuario es el único que tiene acceso a la última parte. Por ejemplo: 36b8f84d-df4e-4d49-b662-bcde71a8764f, el texto es encriptado con toda la uuid y el archivo se guarda con el nombre: 36b8f84d-df4e-4d49-b662 y el usuario es el único que cuenta con la última parte: bcde71a8764f para poder desencriptar el texto.

Para el CSS se utiliza Bootstrap desde una CDN y para encriptar el texto dentro del navegador se utiliza CryptoJS también desde una CDN pública y en este ejemplo se utiliza AES para encriptar el texto, pero se puede utilizar cualquier otra librería o método.

El “bucket” de S3 se configura para borrar los archivos dentro de 30 días en mi caso (pueden ser más o menos), ya que si no son vistos, no pueden ser leídos, ya que solo contienen 2/3 de la llave para desencriptarlos.

Forma para encriptar el texto:

Url para que el usuario pueda recuperar el texto:

Texto desencriptado y borrado del servidor:

Archivos encriptados almacenados sin la llave completa:

Archivo almacenado encriptado:

Y es así como puedo tener un servicio todo dentro de una función de AWS Lambda con un costo apróximado de $0.003 centavos de dólar por cada uso.