Local Encrypted File Storage Using a Single HTML FilesteemCreated with Sketch.

in #coding17 days ago

2026-09-05_153509.png

Opaque Container is a local HTML tool that wraps a single file into an encrypted binary container with no external signature or readable structure.

The script runs entirely in the browser and requires no server, installation, or internet connection. The user selects a file, enters a password, and receives a container with an automatically generated name such as 05-09-2026_15-17-19.

Cryptography:

  • PBKDF2-SHA-256 for password-based key derivation
  • AES-256-GCM for encryption and integrity authentication
  • random 32-byte salt
  • random 12-byte IV
  • Unicode passwords with NFC normalization

The original filename, MIME type, file size, format version, and internal signature are stored only inside the encrypted payload.

2026-09-05_153754.png

Externally, the container contains no magic bytes, format name, algorithm name, program version, or readable metadata. In a hex editor, it appears as high-entropy random-looking binary data.

If the password is incorrect or the file is damaged, AES-GCM authentication fails. The application therefore does not need to distinguish between a wrong password and an invalid container.

The main goal of the project is to create a simple opaque container: the file exists as a binary object, but its purpose and internal structure become known only after successful decryption.

The container stores one file only. If multiple files or directories are needed, they can first be packed into ZIP, 7z, RAR, or another archive.

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Opaque Container</title>
<style>
:root{color-scheme:dark;font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:#111317;color:#eef1f5;display:flex;justify-content:center;padding:40px 18px}
.app{width:min(860px,100%)}
h1{margin:0 0 10px;font-size:clamp(28px,5vw,42px);line-height:1.05}
.subtitle{margin:0 0 28px;color:#aeb6c2;line-height:1.5}
.warning{display:none;margin:0 0 20px;padding:14px 16px;border:1px solid #7f3f3f;border-radius:12px;background:#2a1717;color:#ffd7d7}
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:18px}
.card{background:#191c21;border:1px solid #2b3038;border-radius:16px;padding:22px;box-shadow:0 10px 30px rgba(0,0,0,.18)}
h2{margin:0 0 18px;font-size:21px}
label{display:block;margin:14px 0 7px;color:#cbd1da;font-size:14px}
input[type=file],input[type=password],input[type=text]{width:100%;border:1px solid #363d47;border-radius:10px;background:#101216;color:#f4f6f8;padding:11px 12px;outline:none}
input:focus{border-color:#7e8ea7}
.password-row{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center}
.password-row button{width:auto;min-width:78px;margin:0;padding:11px 13px;background:#2a2f37;color:#eef1f5;border:1px solid #363d47;font-weight:600}
button{width:100%;margin-top:18px;border:0;border-radius:11px;padding:12px 14px;font-weight:700;font-size:15px;cursor:pointer;background:#e9edf3;color:#111317}
button:disabled{cursor:not-allowed;opacity:.55}
.file-info{min-height:20px;margin-top:8px;color:#8f98a5;font-size:13px}
.status{min-height:42px;margin-top:14px;padding:10px 12px;border-radius:10px;background:#111317;color:#aeb6c2;font-size:14px;line-height:1.45;white-space:pre-wrap}
.status.ok{color:#c9f7d7;background:#112018}
.status.error{color:#ffd2d2;background:#261515}
.footnote{margin:20px 0 0;color:#858e9b;font-size:13px;line-height:1.5}
@media(max-width:720px){body{padding-top:22px}.grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<main class="app">
<h1>Opaque Container</h1>
<p class="subtitle">Local single-file prototype. The container stores one encrypted file and exposes no readable signature, original filename, file type, version string, or algorithm name outside encryption.</p>
<div id="cryptoWarning" class="warning"></div>
<div class="grid">

<section class="card">
<h2>Create Container</h2>
<label for="encryptFile">Source file</label>
<input id="encryptFile" type="file">
<div id="encryptFileInfo" class="file-info"></div>

<label for="encryptPassword">Password</label>
<div class="password-row">
<input id="encryptPassword" type="password" autocomplete="new-password">
<button type="button" data-toggle-password="encryptPassword">Show</button>
</div>

<label for="encryptPassword2">Repeat password</label>
<div class="password-row">
<input id="encryptPassword2" type="password" autocomplete="new-password">
<button type="button" data-toggle-password="encryptPassword2">Show</button>
</div>

<button id="encryptButton">Encrypt</button>
<div id="encryptStatus" class="status">Select a file and enter a password.</div>
</section>

<section class="card">
<h2>Open Container</h2>
<label for="decryptFile">Container file</label>
<input id="decryptFile" type="file">
<div id="decryptFileInfo" class="file-info"></div>

<label for="decryptPassword">Password</label>
<div class="password-row">
<input id="decryptPassword" type="password" autocomplete="current-password">
<button type="button" data-toggle-password="decryptPassword">Show</button>
</div>

<button id="decryptButton">Decrypt</button>
<div id="decryptStatus" class="status">Select a container and enter a password.</div>
</section>

</div>
<p class="footnote">The selected file is currently processed in memory as a single encrypted payload. Cryptography: PBKDF2-SHA-256 → AES-256-GCM.</p>
</main>

<script>
(() => {
"use strict";

const SALT_LENGTH=32;
const IV_LENGTH=12;
const PBKDF2_ITERATIONS=600000;
const AES_KEY_BITS=256;
const GCM_TAG_BITS=128;
const INTERNAL_MAGIC="OPAQUE-CONTAINER-1";
const INTERNAL_VERSION=1;

const encoder=new TextEncoder();
const decoder=new TextDecoder();

const encryptFileInput=document.getElementById("encryptFile");
const encryptFileInfo=document.getElementById("encryptFileInfo");
const encryptPassword=document.getElementById("encryptPassword");
const encryptPassword2=document.getElementById("encryptPassword2");
const encryptButton=document.getElementById("encryptButton");
const encryptStatus=document.getElementById("encryptStatus");

const decryptFileInput=document.getElementById("decryptFile");
const decryptFileInfo=document.getElementById("decryptFileInfo");
const decryptPassword=document.getElementById("decryptPassword");
const decryptButton=document.getElementById("decryptButton");
const decryptStatus=document.getElementById("decryptStatus");
const cryptoWarning=document.getElementById("cryptoWarning");

if(!window.crypto||!window.crypto.subtle||!window.crypto.getRandomValues){
  cryptoWarning.style.display="block";
  cryptoWarning.textContent="Web Crypto API is not available in this browser or local-file mode.";
  encryptButton.disabled=true;
  decryptButton.disabled=true;
  return;
}

function normalizePassword(value){
  return String(value).normalize("NFC");
}

function setStatus(element,text,type=""){
  element.textContent=text;
  element.className="status"+(type?" "+type:"");
}

function concatBytes(...parts){
  const total=parts.reduce((sum,part)=>sum+part.byteLength,0);
  const result=new Uint8Array(total);
  let offset=0;
  for(const part of parts){
    const bytes=part instanceof Uint8Array?part:new Uint8Array(part);
    result.set(bytes,offset);
    offset+=bytes.byteLength;
  }
  return result;
}

function uint32ToBytes(value){
  const bytes=new Uint8Array(4);
  new DataView(bytes.buffer).setUint32(0,value,false);
  return bytes;
}

function bytesToUint32(bytes,offset=0){
  return new DataView(bytes.buffer,bytes.byteOffset+offset,4).getUint32(0,false);
}

function safeFilename(name){
  const cleaned=String(name||"restored-file").replace(/[\\/:*?"<>|\u0000-\u001F]/g,"_").trim();
  return cleaned||"restored-file";
}

function pad2(value){
  return String(value).padStart(2,"0");
}

function generateContainerFilename(){
  const now=new Date();
  return pad2(now.getDate())+"-"+pad2(now.getMonth()+1)+"-"+now.getFullYear()+"_"+pad2(now.getHours())+"-"+pad2(now.getMinutes())+"-"+pad2(now.getSeconds());
}

function formatBytes(bytes){
  if(bytes<1024)return bytes+" B";
  const units=["KB","MB","GB","TB"];
  let value=bytes/1024;
  let unitIndex=0;
  while(value>=1024&&unitIndex<units.length-1){
    value/=1024;
    unitIndex++;
  }
  const decimals=value>=100?0:value>=10?1:2;
  return value.toFixed(decimals)+" "+units[unitIndex];
}

function downloadBytes(bytes,filename,mime="application/octet-stream"){
  const blob=new Blob([bytes],{type:mime});
  const url=URL.createObjectURL(blob);
  const link=document.createElement("a");
  link.href=url;
  link.download=filename;
  document.body.appendChild(link);
  link.click();
  link.remove();
  setTimeout(()=>URL.revokeObjectURL(url),1000);
}

async function deriveAesKey(password,salt){
  const normalizedPassword=normalizePassword(password);
  const passwordKey=await crypto.subtle.importKey(
    "raw",
    encoder.encode(normalizedPassword),
    "PBKDF2",
    false,
    ["deriveKey"]
  );

  return crypto.subtle.deriveKey(
    {name:"PBKDF2",hash:"SHA-256",salt,iterations:PBKDF2_ITERATIONS},
    passwordKey,
    {name:"AES-GCM",length:AES_KEY_BITS},
    false,
    ["encrypt","decrypt"]
  );
}

function updateSelectedFileInfo(input,infoElement){
  const file=input.files[0];
  infoElement.textContent=file?file.name+" · "+formatBytes(file.size):"";
}

function clearCreateForm(){
  encryptFileInput.value="";
  encryptPassword.value="";
  encryptPassword2.value="";
  encryptFileInfo.textContent="";
}

function clearOpenForm(){
  decryptFileInput.value="";
  decryptPassword.value="";
  decryptFileInfo.textContent="";
}

async function encryptSelectedFile(){
  const file=encryptFileInput.files[0];
  const password=normalizePassword(encryptPassword.value);
  const confirmation=normalizePassword(encryptPassword2.value);

  if(!file){setStatus(encryptStatus,"Select a source file first.","error");return;}
  if(!password){setStatus(encryptStatus,"Enter a password.","error");return;}
  if(password!==confirmation){setStatus(encryptStatus,"Passwords do not match.","error");return;}

  encryptButton.disabled=true;
  setStatus(encryptStatus,"Encrypting…");

  try{
    const salt=crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
    const iv=crypto.getRandomValues(new Uint8Array(IV_LENGTH));
    const key=await deriveAesKey(password,salt);
    const fileBytes=new Uint8Array(await file.arrayBuffer());

    const metadata={
      magic:INTERNAL_MAGIC,
      version:INTERNAL_VERSION,
      name:file.name,
      type:file.type||"application/octet-stream",
      size:file.size,
      lastModified:file.lastModified||0
    };

    const metadataBytes=encoder.encode(JSON.stringify(metadata));
    const plaintext=concatBytes(
      uint32ToBytes(metadataBytes.byteLength),
      metadataBytes,
      fileBytes
    );

    const ciphertext=new Uint8Array(await crypto.subtle.encrypt(
      {name:"AES-GCM",iv,tagLength:GCM_TAG_BITS},
      key,
      plaintext
    ));

    const container=concatBytes(salt,iv,ciphertext);
    const containerFilename=generateContainerFilename();

    downloadBytes(container,containerFilename);

    setStatus(
      encryptStatus,
      "Encryption complete.\nContainer: "+containerFilename,
      "ok"
    );

    clearCreateForm();
  }catch(error){
    console.error(error);
    setStatus(encryptStatus,"Unable to create container.","error");
  }finally{
    encryptButton.disabled=false;
  }
}

async function decryptSelectedFile(){
  const file=decryptFileInput.files[0];
  const password=normalizePassword(decryptPassword.value);

  if(!file){setStatus(decryptStatus,"Select a container file first.","error");return;}
  if(!password){setStatus(decryptStatus,"Enter a password.","error");return;}

  decryptButton.disabled=true;
  setStatus(decryptStatus,"Decrypting…");

  try{
    const container=new Uint8Array(await file.arrayBuffer());

    if(container.byteLength<SALT_LENGTH+IV_LENGTH+16){
      throw new Error("INVALID_CONTAINER");
    }

    const salt=container.slice(0,SALT_LENGTH);
    const iv=container.slice(SALT_LENGTH,SALT_LENGTH+IV_LENGTH);
    const ciphertext=container.slice(SALT_LENGTH+IV_LENGTH);
    const key=await deriveAesKey(password,salt);

    let plaintextBuffer;
    try{
      plaintextBuffer=await crypto.subtle.decrypt(
        {name:"AES-GCM",iv,tagLength:GCM_TAG_BITS},
        key,
        ciphertext
      );
    }catch{
      throw new Error("AUTH_FAILED");
    }

    const plaintext=new Uint8Array(plaintextBuffer);

    if(plaintext.byteLength<4)throw new Error("INVALID_PAYLOAD");

    const metadataLength=bytesToUint32(plaintext,0);

    if(metadataLength===0||metadataLength>plaintext.byteLength-4||metadataLength>1024*1024){
      throw new Error("INVALID_METADATA");
    }

    const metadataStart=4;
    const metadataEnd=metadataStart+metadataLength;

    let metadata;
    try{
      metadata=JSON.parse(decoder.decode(plaintext.slice(metadataStart,metadataEnd)));
    }catch{
      throw new Error("INVALID_METADATA");
    }

    if(metadata.magic!==INTERNAL_MAGIC||metadata.version!==INTERNAL_VERSION){
      throw new Error("INVALID_FORMAT");
    }

    const restoredBytes=plaintext.slice(metadataEnd);

    if(typeof metadata.size!=="number"||metadata.size!==restoredBytes.byteLength){
      throw new Error("SIZE_MISMATCH");
    }

    const restoredName=safeFilename(metadata.name);
    const restoredMime=typeof metadata.type==="string"&&metadata.type?metadata.type:"application/octet-stream";

    downloadBytes(restoredBytes,restoredName,restoredMime);

    setStatus(
      decryptStatus,
      "Container opened successfully.\nRestored file: "+restoredName,
      "ok"
    );

    clearOpenForm();
  }catch(error){
    console.error(error);
    setStatus(
      decryptStatus,
      "Unable to open container. Incorrect password or invalid file.",
      "error"
    );
  }finally{
    decryptButton.disabled=false;
  }
}

encryptFileInput.addEventListener("change",()=>updateSelectedFileInfo(encryptFileInput,encryptFileInfo));
decryptFileInput.addEventListener("change",()=>updateSelectedFileInfo(decryptFileInput,decryptFileInfo));

encryptButton.addEventListener("click",encryptSelectedFile);
decryptButton.addEventListener("click",decryptSelectedFile);

[encryptPassword,encryptPassword2].forEach(input=>{
  input.addEventListener("keydown",event=>{
    if(event.key==="Enter")encryptSelectedFile();
  });
});

decryptPassword.addEventListener("keydown",event=>{
  if(event.key==="Enter")decryptSelectedFile();
});

document.querySelectorAll("[data-toggle-password]").forEach(button=>{
  button.addEventListener("click",()=>{
    const input=document.getElementById(button.dataset.togglePassword);
    const show=input.type==="password";
    input.type=show?"text":"password";
    button.textContent=show?"Hide":"Show";
  });
});
})();
</script>
</body>
</html>