const $=s=>document.querySelector(s),enc=new TextEncoder(),dec=new TextDecoder(),APP_VERSION='1.0.1',PACKAGE_EXTENSION='.csplat',LEGACY_PACKAGE_EXTENSION='.ciphersplat',MAGIC=enc.encode('NIVORA1!'),MAGIC_TEXT2=enc.encode('CSPTXT2!'),MAGIC_TEXT3=enc.encode('CSPTXT3!'),ITERATIONS=600000;
const ARGON2_MEMORY=65536,ARGON2_ITERATIONS=3,ARGON2_PARALLELISM=1,ARGON2_KEY_LENGTH=32;
let mode='file',operation='encrypt',cryptoMethod='password',selectedEntries=[],resultBlob=null,resultName='',resultText='',resultFiles=[],pgpKeyBinary=null;
const els={fileInput:$('#fileInput'),folderInput:$('#folderInput'),drop:$('#dropZone'),fileCard:$('#fileCard'),text:$('#textInput'),password:$('#password'),run:$('#runButton'),error:$('#errorMessage'),result:$('#resultPanel')};
document.querySelectorAll('.mode-tab').forEach(btn=>{btn.addEventListener('click',()=>selectMode(btn));btn.addEventListener('keydown',e=>{if(!['ArrowLeft','ArrowRight','Home','End'].includes(e.key))return;e.preventDefault();const tabs=[...document.querySelectorAll('.mode-tab')],index=e.key==='Home'?0:e.key==='End'?tabs.length-1:(tabs.indexOf(btn)+(e.key==='ArrowRight'?1:-1)+tabs.length)%tabs.length;tabs[index].focus();selectMode(tabs[index])})});
function selectMode(btn){mode=btn.dataset.mode;document.querySelectorAll('.mode-tab').forEach(b=>{const active=b===btn;b.classList.toggle('active',active);b.setAttribute('aria-selected',active);b.tabIndex=active?0:-1});$('#fileInputArea').classList.toggle('hidden',mode!=='file');$('#textInputArea').classList.toggle('hidden',mode!=='text');resetResult();sync()}
function setOperation(next){operation=next;if(operation==='verify'&&mode!=='file')selectMode($('#fileTab'));document.querySelectorAll('.op').forEach(b=>{const active=b.dataset.op===operation;b.classList.toggle('active',active);b.setAttribute('aria-pressed',active)});const encrypting=operation==='encrypt',pgp=cryptoMethod==='openpgp';$('#dropTitle').textContent=encrypting?(pgp?'Drop one file to encrypt for a public key':'Drop files or a folder into the vault'):(operation==='verify'?'Drop one encrypted package to authenticate':`Drop one ${pgp?'OpenPGP':'.csplat or legacy .ciphersplat'} package to unlock`);$('#dropSub').textContent=encrypting?(pgp?'OpenPGP mode handles one file per operation':'recursive paths are detected automatically'):(operation==='verify'?'contents will be authenticated but not restored':pgp?'original file bytes will be restored after authentication':'folder structure will be restored after authentication');$('#chooseFolder').classList.toggle('hidden',!encrypting||pgp);els.fileInput.multiple=encrypting&&!pgp;els.text.placeholder=encrypting?(pgp?'Paste text to encrypt for the public-key holder':'Paste a private note, recovery code, or message…'):(pgp?'Paste an ASCII-armored OpenPGP message…':'Paste a CipherSplat encrypted text package…');syncCredentialPanel();clearSelection();resetResult();sync()}
document.querySelectorAll('.op').forEach(btn=>btn.addEventListener('click',()=>setOperation(btn.dataset.op)));
function setCryptoMethod(next){cryptoMethod=next;document.querySelectorAll('.method').forEach(button=>{const active=button.dataset.method===next;button.classList.toggle('active',active);button.setAttribute('aria-pressed',active)});$('#passwordCredentials').classList.toggle('hidden',next!=='password');$('#pgpCredentials').classList.toggle('hidden',next!=='openpgp');const pgp=next==='openpgp';$('#algorithmLabel').textContent=pgp?'OpenPGP message':'AES-256-GCM';$('#algorithmCopy').textContent=pgp?'Recipient-key encryption + integrity':'Authenticated encryption';$('#keyMethodLabel').textContent=pgp?'OpenPGP.js 6.3.1':'Argon2id v1.3';$('#keyMethodCopy').textContent=pgp?'Pinned local dependency':'64 MiB · 3 passes';clearPgpCredentials();setOperation(operation)}
document.querySelectorAll('.method').forEach(button=>button.addEventListener('click',()=>setCryptoMethod(button.dataset.method)));
els.fileInput.addEventListener('change',()=>setEntries([...els.fileInput.files].map(file=>({file,path:file.name}))));
els.folderInput.addEventListener('change',()=>setEntries([...els.folderInput.files].map(file=>({file,path:file.webkitRelativePath||file.name}))));
$('#chooseFiles').addEventListener('click',e=>{e.stopPropagation();els.fileInput.click()});
$('#chooseFolder').addEventListener('click',e=>{e.stopPropagation();els.folderInput.click()});
['dragenter','dragover'].forEach(type=>els.drop.addEventListener(type,e=>{e.preventDefault();els.drop.classList.add('drag')}));
['dragleave','drop'].forEach(type=>els.drop.addEventListener(type,e=>{e.preventDefault();els.drop.classList.remove('drag')}));
els.drop.addEventListener('drop',async e=>{const found=[];for(const item of e.dataTransfer.items){const entry=item.webkitGetAsEntry?.();if(entry)await walkEntry(entry,'',found)}setEntries(found.length?found:[...e.dataTransfer.files].map(file=>({file,path:file.name})))});
$('#removeFile').addEventListener('click',()=>{clearSelection();resetStatus();sync()});
els.text.addEventListener('input',()=>{$('#charCount').textContent=els.text.value.length;sync()});
els.password.addEventListener('input',()=>{updateStrength();sync()});
$('#showPassword').addEventListener('click',e=>{const showing=els.password.type==='password';els.password.type=showing?'text':'password';e.currentTarget.setAttribute('aria-label',showing?'Hide password':'Show password');e.currentTarget.setAttribute('aria-pressed',showing)});
$('#generatePassword').addEventListener('click',openPasswordGenerator);
els.run.addEventListener('click',runRequested);
$('#downloadResult').addEventListener('click',downloadResult);
$('#copyResult').addEventListener('click',async()=>{await navigator.clipboard.writeText(resultText);$('#copyResult').textContent='Copied ✓';setTimeout(()=>$('#copyResult').textContent=resultFiles.length?'Copy result':'Copy encrypted text',1600)});
function clearSelection(){selectedEntries=[];els.fileInput.value='';els.folderInput.value='';els.fileCard.classList.add('hidden');els.drop.classList.remove('hidden')}
function setEntries(entries){if(!entries.length)return;if((operation!=='encrypt'||cryptoMethod==='openpgp')&&entries.length>1){els.error.textContent='Choose one encrypted package or file for this operation.';return}selectedEntries=entries;const folder=entries.length>1||entries[0].path.includes('/'),total=entries.reduce((n,e)=>n+e.file.size,0);$('#fileName').textContent=folder?`${entries[0].path.split('/')[0]} · ${entries.length} files`:entries[0].file.name;$('#fileSize').textContent=`${formatBytes(total)}${folder?' · recursive package':''}`;els.drop.classList.add('hidden');els.fileCard.classList.remove('hidden');resetResult();sync()}
async function walkEntry(entry,prefix,out){if(entry.isFile){const file=await new Promise((resolve,reject)=>entry.file(resolve,reject));out.push({file,path:prefix+file.name});return}if(entry.isDirectory){const reader=entry.createReader();let batch;do{batch=await new Promise((resolve,reject)=>reader.readEntries(resolve,reject));for(const child of batch)await walkEntry(child,prefix+entry.name+'/',out)}while(batch.length)}}
function sync(){const hasInput=mode==='file'?selectedEntries.length:els.text.value.trim(),hasCredential=cryptoMethod==='password'?Boolean(els.password.value):Boolean($('#pgpKeyInput').value.trim()||pgpKeyBinary),action=operation==='encrypt'?'Encrypt':operation==='decrypt'?'Decrypt':'Verify';els.run.disabled=!hasInput||!hasCredential||(operation==='verify'&&mode!=='file');$('#runLabel').textContent=`${action} ${mode==='file'&&selectedEntries.length>1?'folder':operation==='verify'?'package':mode}`}
function updateStrength(){const p=els.password.value;let score=0;if(p.length>=12)score++;if(p.length>=18)score++;if(/[A-Z]/.test(p)&&/[a-z]/.test(p))score++;if(/\d/.test(p)&&/[^\w]/.test(p))score++;const labels=['ENTER A PASSWORD','WEAK','FAIR','STRONG','EXCELLENT'],strength=$('.strength');strength.className=`strength strength-${score}`;$('#strengthText').textContent=labels[score]}
function formatBytes(n){if(n<1024)return n+' B';if(n<1048576)return(n/1024).toFixed(1)+' KB';return(n/1048576).toFixed(1)+' MB'}
function progress(n,stage){$('#progressTrack').value=n;$('#progressTrack').textContent=n+'%';$('#progressNumber').textContent=n+'%';$('#progressStage').textContent=stage;if(n===100||/stopped|failed/i.test(stage))$('.status-panel').setAttribute('aria-busy','false')}
function setStatusChip(label,state=''){const chip=$('#statusChip');chip.textContent=label;chip.className=`status-chip ${state}`.trim()}
function setWorking(){els.error.textContent='';els.run.disabled=true;$('.status-panel').setAttribute('aria-busy','true');setStatusChip('PROCESSING','processing');$('#statusTitle').textContent=operation==='encrypt'?'Building your vault…':'Verifying your vault…';$('#statusCopy').textContent='Key material exists only in this browser tab.'}
function resetStatus(){progress(0,'Waiting for input');$('.status-panel').setAttribute('aria-busy','false');setStatusChip('READY');$('#statusTitle').textContent='Vault standing by';$('#statusCopy').textContent='Select a file or enter text to begin. All processing stays within this tab.'}
function resetResult(){els.result.classList.add('hidden');resultBlob=null;resultText='';resultFiles=[];$('#downloadResult').textContent='Download result ↓';$('#downloadResult').classList.remove('hidden');resetStatus()}
function concat(...arrays){const out=new Uint8Array(arrays.reduce((n,a)=>n+a.length,0));let off=0;for(const a of arrays){out.set(a,off);off+=a.length}return out}
function u32(n){const a=new Uint8Array(4);new DataView(a.buffer).setUint32(0,n);return a}
async function derive(password,salt,iterations=ITERATIONS){const material=await crypto.subtle.importKey('raw',enc.encode(password),'PBKDF2',false,['deriveKey']);return crypto.subtle.deriveKey({name:'PBKDF2',salt,iterations,hash:'SHA-256'},material,{name:'AES-GCM',length:256},false,['encrypt','decrypt'])}
function validateArgon2(h){if(h.kdf!=='Argon2id-v1.3'||h.memory!==ARGON2_MEMORY||h.passes!==ARGON2_ITERATIONS||h.parallelism!==ARGON2_PARALLELISM||h.keyLength!==ARGON2_KEY_LENGTH)throw Error('Unsupported or unsafe Argon2id parameters.')}
async function argon2Bytes(password,salt,h){validateArgon2(h);const passwordBytes=enc.encode(password),saltBytes=salt.slice();try{if(typeof Worker==='function'){const key=await new Promise((resolve,reject)=>{const worker=new Worker('argon2-worker.js'),timer=setTimeout(()=>{worker.terminate();reject(Error('Argon2id key derivation timed out.'))},120000);worker.onmessage=event=>{clearTimeout(timer);worker.terminate();event.data.error?reject(Error(event.data.error)):resolve(new Uint8Array(event.data.key))};worker.onerror=event=>{clearTimeout(timer);worker.terminate();reject(Error(event.message||'Argon2id worker failed.'))};worker.postMessage({password:passwordBytes.buffer,salt:saltBytes.buffer,memorySize:h.memory,iterations:h.passes,parallelism:h.parallelism,hashLength:h.keyLength},[passwordBytes.buffer,saltBytes.buffer])});return key}}catch{}if(!window.hashwasm?.argon2id)throw Error('The pinned Argon2id engine is unavailable.');return window.hashwasm.argon2id({password:enc.encode(password),salt:salt.slice(),memorySize:h.memory,iterations:h.passes,parallelism:h.parallelism,hashLength:h.keyLength,outputType:'binary'})}
async function deriveArgon2(password,salt,h){const bytes=await argon2Bytes(password,salt,h);try{return await crypto.subtle.importKey('raw',bytes,{name:'AES-GCM'},false,['encrypt','decrypt'])}finally{bytes.fill(0)}}
async function deriveForHeader(password,salt,h){return h.kdf==='Argon2id-v1.3'?deriveArgon2(password,salt,h):derive(password,salt,h.iter)}
function makePlain(kind,name,data,entries){const meta=enc.encode(JSON.stringify({kind,name,date:new Date().toISOString(),entries}));return concat(u32(meta.length),meta,data)}
function validatePlainMetadata(meta,dataLength){if(!meta||!['text','file','folder'].includes(meta.kind)||typeof meta.name!=='string'||!meta.name||meta.name.length>4096)throw Error('Invalid vault metadata.');if(meta.kind==='text'){if(meta.entries!==undefined&&!Array.isArray(meta.entries))throw Error('Invalid vault metadata.');return meta}if(!Array.isArray(meta.entries)||!meta.entries.length||meta.entries.length>MAX_FILES)throw Error('Invalid vault metadata.');let total=0;for(const entry of meta.entries){if(!entry||!safePath(entry.path)||!Number.isSafeInteger(entry.size)||entry.size<0||typeof entry.type!=='string'||entry.type.length>255)throw Error('Invalid vault metadata.');total+=entry.size;if(!Number.isSafeInteger(total)||total>dataLength)throw Error('Invalid vault metadata.')}if(total!==dataLength)throw Error('Invalid vault payload size.');return meta}
function readPlain(bytes){if(bytes.length<4)throw Error('Invalid vault payload.');const len=new DataView(bytes.buffer,bytes.byteOffset,4).getUint32(0);if(len<2||len>MAX_META_SIZE||len>bytes.length-4)throw Error('Invalid vault payload.');let meta;try{meta=JSON.parse(dec.decode(bytes.slice(4,4+len)))}catch{throw Error('Invalid vault metadata.')}const data=bytes.slice(4+len);validatePlainMetadata(meta,data.length);return{meta,data}}
function containerAad(magic,header,counter){const base=concat(magic,u32(header.length),header);return counter===undefined?base:concat(base,u32(counter))}
async function encryptData(data,kind,name,entries){const salt=crypto.getRandomValues(new Uint8Array(16)),iv=crypto.getRandomValues(new Uint8Array(12)),params={v:3,alg:'AES-256-GCM',kdf:'Argon2id-v1.3',memory:ARGON2_MEMORY,passes:ARGON2_ITERATIONS,parallelism:ARGON2_PARALLELISM,keyLength:ARGON2_KEY_LENGTH,salt:b64(salt),iv:b64(iv)};progress(12,'Generating salt + nonce');await tick();progress(28,'Hardening password with Argon2id');const key=await deriveArgon2(els.password.value,salt,params),header=enc.encode(JSON.stringify(params)),aad=containerAad(MAGIC_TEXT3,header);progress(68,'Encrypting with AES-256-GCM');const cipher=new Uint8Array(await crypto.subtle.encrypt({name:'AES-GCM',iv,additionalData:aad,tagLength:128},key,makePlain(kind,name,data,entries)));progress(94,'Sealing authenticated container');await tick();return concat(aad,cipher)}
async function decryptData(container){if(container.length<29)throw Error('This is not a valid CipherSplat text package.');const legacy=MAGIC.every((b,i)=>container[i]===b),v2=MAGIC_TEXT2.every((b,i)=>container[i]===b),current=MAGIC_TEXT3.every((b,i)=>container[i]===b);if(!legacy&&!v2&&!current)throw Error('This is not a valid CipherSplat text package.');const hlen=new DataView(container.buffer,container.byteOffset+8,4).getUint32(0);if(hlen<2||hlen>4096||12+hlen+16>=container.length)throw Error('The vault header is invalid.');const header=container.slice(12,12+hlen);let h;try{h=JSON.parse(dec.decode(header))}catch{throw Error('The vault header is invalid.')}if((legacy&&h.v!==1)||(v2&&h.v!==2)||(current&&h.v!==3)||h.alg!=='AES-256-GCM')throw Error('Unsupported or unsafe vault parameters.');if(current)validateArgon2(h);else if(h.kdf!=='PBKDF2-SHA-256'||!Number.isInteger(h.iter)||h.iter<100000||h.iter>2000000)throw Error('Unsupported or unsafe vault parameters.');const salt=fromB64(h.salt),iv=fromB64(h.iv);if(salt.length!==16||iv.length!==12)throw Error('Invalid package key parameters.');progress(20,'Reading vault parameters');const key=await deriveForHeader(els.password.value,salt,h);progress(62,'Verifying authentication tag');let plain;try{const algorithm={name:'AES-GCM',iv,tagLength:128};if(v2)algorithm.additionalData=containerAad(MAGIC_TEXT2,header);if(current)algorithm.additionalData=containerAad(MAGIC_TEXT3,header);plain=new Uint8Array(await crypto.subtle.decrypt(algorithm,key,container.slice(12+hlen)))}catch{throw Error('Unlock failed. The password is wrong or the data was altered.')}progress(90,'Restoring protected data');return readPlain(plain)}
function b64(bytes){let s='';for(let i=0;i<bytes.length;i+=0x8000)s+=String.fromCharCode(...bytes.subarray(i,i+0x8000));return btoa(s)}
function fromB64(s){if(typeof s!=='string'||!s.length||s.length%4===1||!/^[A-Za-z0-9+/]*={0,2}$/.test(s))throw Error('The encrypted text contains invalid Base64 data.');let raw;try{raw=atob(s)}catch{throw Error('The encrypted text contains invalid Base64 data.')}const out=new Uint8Array(raw.length);for(let i=0;i<raw.length;i++)out[i]=raw.charCodeAt(i);return out}
const tick=()=>new Promise(r=>setTimeout(r,180));
async function bundleEntries(){const chunks=[],meta=[];for(const entry of selectedEntries){const bytes=new Uint8Array(await entry.file.arrayBuffer());meta.push({path:entry.path.replace(/^\/+|\.\.(\/|\\)/g,''),size:bytes.length,type:entry.file.type});chunks.push(bytes)}return{data:concat(...chunks),meta}}
function unpackEntries(data,entries){let off=0;return entries.map(e=>{const bytes=data.slice(off,off+e.size);off+=e.size;return{path:e.path,blob:new Blob([bytes],{type:e.type||'application/octet-stream'})}})}
async function downloadResult(){if(resultFiles.length){if('showDirectoryPicker'in window){if(!confirm('Restore into a new or empty folder when possible. Existing files with matching names will be overwritten. Continue?'))return;let root;try{root=await window.showDirectoryPicker({mode:'readwrite'})}catch(e){if(e.name!=='AbortError')els.error.textContent='The destination folder could not be opened.';return}for(const item of resultFiles){if(!safePath(item.path))throw Error('Unsafe restoration path was blocked.');const parts=item.path.split('/');let dir=root;for(const part of parts.slice(0,-1))dir=await dir.getDirectoryHandle(part,{create:true});const handle=await dir.getFileHandle(parts.at(-1),{create:true}),writer=await handle.createWritable();try{await writer.write(item.blob);await writer.close()}catch(e){await writer.abort().catch(()=>{});throw e}}$('#downloadResult').textContent='Folder restored ✓'}else{if(!confirm(`Your browser will download ${resultFiles.length} files and flatten folder separators in their names. Continue?`))return;for(const item of resultFiles){const a=document.createElement('a');a.href=URL.createObjectURL(item.blob);a.download=item.path.replaceAll('/','__');a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)}}return}const a=document.createElement('a');a.href=URL.createObjectURL(resultBlob);a.download=resultName;a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000)}
async function getOpenPgp(){if(!window.openpgp)throw Error('The pinned OpenPGP engine did not load. Verify the application files against integrity.json.');return window.openpgp}
function syncCredentialPanel(){if(cryptoMethod!=='openpgp')return;const encrypting=operation==='encrypt';$('#pgpKeyLabel').textContent=encrypting?'Recipient public key':'Recipient private key';$('#pgpKeyInput').placeholder=encrypting?'Paste an ASCII-armored OpenPGP public key':'Paste an ASCII-armored OpenPGP private key';$('#pgpPassphraseRow').classList.toggle('hidden',encrypting);$('#pgpKeyStatus').textContent=encrypting?'The public key identifies who can decrypt. It is not uploaded or saved.':'The private key and passphrase stay in memory only and are cleared after this operation.'}
function clearPgpCredentials(){pgpKeyBinary=null;$('#pgpKeyInput').value='';$('#pgpPassphrase').value='';$('#pgpPassphrase').type='password';if(cryptoMethod==='openpgp')syncCredentialPanel()}
$('#choosePgpKey').addEventListener('click',()=>$('#pgpKeyFile').click());
$('#pgpKeyFile').addEventListener('change',async()=>{const file=$('#pgpKeyFile').files[0];if(!file)return;const bytes=new Uint8Array(await file.arrayBuffer()),text=dec.decode(bytes);if(text.trimStart().startsWith('-----BEGIN PGP')){$('#pgpKeyInput').value=text;pgpKeyBinary=null}else{$('#pgpKeyInput').value='';pgpKeyBinary=bytes}$('#pgpKeyStatus').textContent=`Loaded ${file.name} · ${formatBytes(file.size)} · held in memory only`;$('#pgpKeyFile').value='';sync()});
$('#pgpKeyInput').addEventListener('input',()=>{pgpKeyBinary=null;sync()});
$('#pgpPassphrase').addEventListener('input',sync);
$('#showPgpPassphrase').addEventListener('click',event=>{const input=$('#pgpPassphrase'),showing=input.type==='password';input.type=showing?'text':'password';event.currentTarget.setAttribute('aria-label',showing?'Hide private-key passphrase':'Show private-key passphrase');event.currentTarget.setAttribute('aria-pressed',showing)});
async function readOpenPgpKey(openpgp,privateKey=false){const armored=$('#pgpKeyInput').value.trim();let key;if(privateKey)key=pgpKeyBinary?await openpgp.readPrivateKey({binaryKey:pgpKeyBinary}):await openpgp.readPrivateKey({armoredKey:armored});else key=pgpKeyBinary?await openpgp.readKey({binaryKey:pgpKeyBinary}):await openpgp.readKey({armoredKey:armored});if(await key.isRevoked())throw Error('This OpenPGP key is revoked.');const expires=await key.getExpirationTime();if(expires instanceof Date&&expires<=new Date())throw Error('This OpenPGP key is expired.');if(privateKey&&!key.isDecrypted()){const passphrase=$('#pgpPassphrase').value;if(!passphrase)throw Error('Enter the private-key passphrase.');key=await openpgp.decryptKey({privateKey:key,passphrase})}$('#pgpKeyStatus').textContent=`Fingerprint ${key.getFingerprint().toUpperCase().match(/.{1,4}/g).join(' ')} · validated locally`;return key}
async function blobFromOpenPgpData(data,type){if(data instanceof Uint8Array||typeof data==='string')return new Blob([data],{type});return new Blob([await new Response(data).arrayBuffer()],{type})}
async function runOpenPgp(){resetResult();setWorking();try{if(mode==='file'&&selectedEntries.length!==1)throw Error('OpenPGP mode accepts one file per operation.');const openpgp=await getOpenPgp();progress(10,'Loading pinned OpenPGP engine');if(operation==='encrypt'){const publicKey=await readOpenPgpKey(openpgp,false);progress(28,'Validating recipient public key');const source=mode==='text'?await openpgp.createMessage({text:els.text.value}):await openpgp.createMessage({binary:selectedEntries[0].file.stream(),filename:selectedEntries[0].file.name});const data=await openpgp.encrypt({message:source,encryptionKeys:publicKey,format:mode==='text'?'armored':'binary'});progress(92,'Finalizing OpenPGP message');resultBlob=await blobFromOpenPgpData(data,mode==='text'?'application/pgp-encrypted':'application/octet-stream');resultName=mode==='text'?'encrypted-message.asc':selectedEntries[0].file.name+'.pgp';resultText=mode==='text'?await resultBlob.text():'';$('#resultTitle').textContent='OpenPGP encryption complete';$('#resultMeta').textContent=`${formatBytes(resultBlob.size)} · encrypted to ${publicKey.getFingerprint().slice(-16).toUpperCase()}`;$('#copyResult').classList.toggle('hidden',mode!=='text')}else{const privateKey=await readOpenPgpKey(openpgp,true);progress(30,'Reading OpenPGP message');const message=mode==='text'?await openpgp.readMessage({armoredMessage:els.text.value.trim()}):await openpgp.readMessage({binaryMessage:selectedEntries[0].file.stream()});const decrypted=await openpgp.decrypt({message,decryptionKeys:privateKey,format:mode==='text'?'utf8':'binary'});progress(92,'Integrity checks complete');if(operation==='verify'){resultBlob=null;resultText='';$('#resultTitle').textContent='OpenPGP integrity confirmed';$('#resultMeta').textContent='The message decrypted and passed integrity checks; plaintext was discarded. This does not verify the sender\'s identity or a signature.';$('#downloadResult').classList.add('hidden');$('#copyResult').classList.add('hidden')}else{resultBlob=await blobFromOpenPgpData(decrypted.data,mode==='text'?'text/plain':'application/octet-stream');resultName=mode==='text'?'decrypted-message.txt':(message.getFilename?.()||selectedEntries[0].file.name.replace(/\.(pgp|gpg|asc)$/i,'')||'decrypted-file');resultText=mode==='text'?await resultBlob.text():'';$('#resultTitle').textContent='OpenPGP message unlocked';$('#resultMeta').textContent=`${formatBytes(resultBlob.size)} · private key held in memory only`;$('#copyResult').classList.toggle('hidden',mode!=='text')}}progress(100,'Complete — OpenPGP integrity checks passed');setStatusChip('SECURED','secured');$('#statusTitle').textContent=operation==='verify'?'Message integrity confirmed':operation==='encrypt'?'Message sealed':'Message authenticated';$('#statusCopy').textContent='OpenPGP processing completed locally. No key or data left this device.';els.result.classList.remove('hidden')}catch(error){els.error.textContent=error.message||String(error);setStatusChip('STOPPED');$('#statusTitle').textContent='OpenPGP operation failed';$('#statusCopy').textContent='No output was created. Check the key, passphrase, and input.';progress(0,'Operation stopped')}finally{clearPgpCredentials();sync()}}
function runRequested(){return cryptoMethod==='openpgp'?runOpenPgp():runLarge()}
async function run(){if(!crypto.subtle){els.error.textContent='Web Crypto is unavailable. Open this page via HTTPS or localhost.';return}resetResult();setWorking();try{if(operation==='encrypt'){let data,kind,name,entries;if(mode==='file'){const bundle=await bundleEntries();data=bundle.data;entries=bundle.meta;kind=entries.length>1||entries[0].path.includes('/')?'folder':'file';name=kind==='folder'?entries[0].path.split('/')[0]:selectedEntries[0].file.name}else{data=enc.encode(els.text.value);kind='text';name='private-note.txt'}const output=await encryptData(data,kind,name,entries);resultName=(name||'vault')+'.prism';if(mode==='text'){resultText='PRISMVAULT3.'+b64(output);resultBlob=new Blob([resultText],{type:'text/plain'});resultName='encrypted-note.prism.txt'}else resultBlob=new Blob([output],{type:'application/octet-stream'});$('#resultTitle').textContent='Encryption complete';$('#resultMeta').textContent=`${formatBytes(resultBlob.size)} · ${entries?.length||1} item(s) · Argon2id + AES-256-GCM`;$('#copyResult').classList.toggle('hidden',mode!=='text')}else{const packed=mode==='file'?new Uint8Array(await selectedEntries[0].file.arrayBuffer()):fromB64(els.text.value.trim().replace(/^PRISMVAULT[123]\./,'')),unlocked=await decryptData(packed);resultName=unlocked.meta.name||'decrypted-data';if(unlocked.meta.kind==='folder'){resultFiles=unpackEntries(unlocked.data,unlocked.meta.entries);resultBlob=new Blob([]);$('#downloadResult').textContent='Restore folder ↓'}else{resultBlob=new Blob([unlocked.data],{type:unlocked.meta.entries?.[0]?.type||'application/octet-stream'});resultText=unlocked.meta.kind==='text'?dec.decode(unlocked.data):''}$('#resultTitle').textContent='Vault unlocked safely';$('#resultMeta').textContent=`${unlocked.meta.entries?.length||1} item(s) · authentication verified`;$('#copyResult').classList.toggle('hidden',!resultText);if(resultText)$('#copyResult').textContent='Copy decrypted text'}progress(100,'Complete — authentication verified');setStatusChip('SECURED','secured');$('#statusTitle').textContent=operation==='encrypt'?'Vault sealed':'Vault authenticated';$('#statusCopy').textContent='The operation completed locally. Your key has not left this tab.';els.result.classList.remove('hidden')}catch(e){els.error.textContent=e.message||String(e);setStatusChip('STOPPED');$('#statusTitle').textContent='Vault operation failed';$('#statusCopy').textContent='No output was created. Check the input and password, then try again.';progress(0,'Operation stopped')}finally{sync()}}
const MAGIC2=enc.encode('NIVORA2!'),MAGIC3=enc.encode('CSPCS3!!'),MAGIC4=enc.encode('CSPCS4!!');
const CHUNK_SIZE=/Android|iPhone|iPad|iPod/i.test(navigator.userAgent)?1024*1024:4*1024*1024;
function ivFor(prefix,counter){const iv=new Uint8Array(12);iv.set(prefix);new DataView(iv.buffer).setUint32(8,counter);return iv}
const MAX_CHUNK_SIZE=8*1024*1024,MAX_META_SIZE=8*1024*1024,MAX_FILES=100000,MAX_RECORDS=0xffffffff;
function safePath(path){if(typeof path!=='string'||!path||path.length>4096||path.includes('\0'))return false;const parts=path.replace(/\\/g,'/').split('/');return parts.every(p=>p&&p!=='.'&&p!=='..'&&p.length<=255)}
function validateMetadata(meta,chunkSize){if(!meta||!['file','folder'].includes(meta.kind)||!Array.isArray(meta.files)||!meta.files.length||meta.files.length>MAX_FILES)throw Error('Invalid or excessive package metadata.');let records=1,total=0;for(const entry of meta.files){if(!entry||!safePath(entry.path)||!Number.isSafeInteger(entry.size)||entry.size<0||!Number.isSafeInteger(entry.chunks)||entry.chunks!==Math.ceil(entry.size/chunkSize))throw Error('Invalid file metadata in package.');records+=entry.chunks;total+=entry.size;if(!Number.isSafeInteger(total)||records>MAX_RECORDS)throw Error('Package exceeds safe processing limits.')}return meta}
function recordAad(format,header,counter){return format===4?containerAad(MAGIC4,header,counter):format===3?containerAad(MAGIC3,header,counter):enc.encode(`NV2:${counter}`)}
async function sealRecord(key,prefix,counter,plain,header){if(!Number.isSafeInteger(counter)||counter<0||counter>MAX_RECORDS)throw Error('Package has too many encrypted records.');if(counter===0&&plain.length>MAX_META_SIZE)throw Error('This folder contains too much metadata for one safe package.');const cipher=new Uint8Array(await crypto.subtle.encrypt({name:'AES-GCM',iv:ivFor(prefix,counter),additionalData:recordAad(4,header,counter),tagLength:128},key,plain));return new Blob([u32(plain.length),u32(cipher.length),cipher])}
async function openRecord(file,offset,key,prefix,counter,maxPlain,format,header){if(!Number.isSafeInteger(offset)||offset<0||offset+8>file.size||counter>MAX_RECORDS)throw Error('The encrypted package ended unexpectedly.');const lens=new Uint8Array(await file.slice(offset,offset+8).arrayBuffer()),view=new DataView(lens.buffer),plainLen=view.getUint32(0),cipherLen=view.getUint32(4);if(cipherLen!==plainLen+16||plainLen>maxPlain||offset+8+cipherLen>file.size)throw Error('Invalid encrypted chunk length.');const cipher=await file.slice(offset+8,offset+8+cipherLen).arrayBuffer();let plain;try{plain=await crypto.subtle.decrypt({name:'AES-GCM',iv:ivFor(prefix,counter),additionalData:recordAad(format,header,counter),tagLength:128},key,cipher)}catch{throw Error('Unlock failed. The password is wrong or the package was altered.')}return{bytes:new Uint8Array(plain),next:offset+8+cipherLen}}
async function encryptFilesChunked(){const salt=crypto.getRandomValues(new Uint8Array(16)),prefix=crypto.getRandomValues(new Uint8Array(8)),params={v:4,alg:'AES-256-GCM-CHUNKED',kdf:'Argon2id-v1.3',memory:ARGON2_MEMORY,passes:ARGON2_ITERATIONS,parallelism:ARGON2_PARALLELISM,keyLength:ARGON2_KEY_LENGTH,salt:b64(salt),prefix:b64(prefix),chunkSize:CHUNK_SIZE};progress(8,'Hardening password with Argon2id');const key=await deriveArgon2(els.password.value,salt,params),total=selectedEntries.reduce((n,e)=>n+e.file.size,0);let counter=0,done=0;const files=selectedEntries.map(e=>({path:e.path.replace(/\\/g,'/').replace(/^\/+|(^|\/)\.\.(\/|$)/g,''),size:e.file.size,type:e.file.type,chunks:Math.ceil(e.file.size/CHUNK_SIZE)}));const name=files.length>1||files[0].path.includes('/')?files[0].path.split('/')[0]:files[0].path,header=enc.encode(JSON.stringify(params));const parts=[MAGIC4,u32(header.length),header];parts.push(await sealRecord(key,prefix,counter++,enc.encode(JSON.stringify({kind:files.length>1||files[0].path.includes('/')?'folder':'file',name,files})),header));for(let f=0;f<selectedEntries.length;f++){const file=selectedEntries[f].file;for(let offset=0;offset<file.size;offset+=CHUNK_SIZE){const bytes=new Uint8Array(await file.slice(offset,Math.min(offset+CHUNK_SIZE,file.size)).arrayBuffer());parts.push(await sealRecord(key,prefix,counter++,bytes,header));done+=bytes.length;progress(30+Math.round(64*done/Math.max(total,1)),`Encrypting ${f+1} of ${files.length} · ${formatBytes(done)}`);await new Promise(requestAnimationFrame)}}progress(98,'Finalizing chunked package');return{blob:new Blob(parts,{type:'application/octet-stream'}),name:name+'.nivora',count:files.length}}
async function decryptFilesChunked(file){if(file.size<29)throw Error('This package is too short to be valid.');const prefixBytes=new Uint8Array(await file.slice(0,12).arrayBuffer()),legacy=MAGIC2.every((b,i)=>prefixBytes[i]===b),v3=MAGIC3.every((b,i)=>prefixBytes[i]===b),current=MAGIC4.every((b,i)=>prefixBytes[i]===b);if(!legacy&&!v3&&!current)throw Error('This is not a CipherSplat package.');const format=current?4:v3?3:2,hlen=new DataView(prefixBytes.buffer).getUint32(8);if(hlen<2||hlen>4096||12+hlen+24>file.size)throw Error('Invalid package header.');const header=new Uint8Array(await file.slice(12,12+hlen).arrayBuffer());let h;try{h=JSON.parse(dec.decode(header))}catch{throw Error('Invalid package header encoding.')}if(h.v!==format||h.alg!=='AES-256-GCM-CHUNKED'||!Number.isInteger(h.chunkSize)||h.chunkSize<65536||h.chunkSize>MAX_CHUNK_SIZE)throw Error('Unsupported or unsafe package parameters.');if(current)validateArgon2(h);else if(h.kdf!=='PBKDF2-SHA-256'||!Number.isInteger(h.iter)||h.iter<100000||h.iter>2000000)throw Error('Unsupported or unsafe package parameters.');const salt=fromB64(h.salt),prefix=fromB64(h.prefix);if(salt.length!==16||prefix.length!==8)throw Error('Invalid package key parameters.');progress(12,current?'Hardening password with Argon2id':'Hardening password');const key=await deriveForHeader(els.password.value,salt,h);let offset=12+hlen,counter=0;const metaRecord=await openRecord(file,offset,key,prefix,counter++,MAX_META_SIZE,format,header);offset=metaRecord.next;let meta;try{meta=validateMetadata(JSON.parse(dec.decode(metaRecord.bytes)),h.chunkSize)}catch(e){throw Error(e.message==='Invalid or excessive package metadata.'||e.message==='Invalid file metadata in package.'||e.message==='Package exceeds safe processing limits.'?e.message:'Invalid encrypted metadata.')}const files=[];let completed=0;for(const entry of meta.files){const parts=[];let restored=0;for(let c=0;c<entry.chunks;c++){const record=await openRecord(file,offset,key,prefix,counter++,h.chunkSize,format,header);offset=record.next;parts.push(record.bytes);restored+=record.bytes.length;completed+=record.bytes.length;progress(25+Math.round(70*offset/file.size),`Authenticating ${formatBytes(completed)}`);await new Promise(requestAnimationFrame)}if(restored!==entry.size)throw Error('Authenticated file size does not match package metadata.');files.push({path:entry.path,blob:new Blob(parts,{type:typeof entry.type==='string'?entry.type.slice(0,255):'application/octet-stream'})})}if(offset!==file.size)throw Error('The package contains unexpected trailing data.');return{meta,files}}
async function runLarge(){if(mode==='text')return run();if(!crypto.subtle){els.error.textContent='Web Crypto is unavailable. Use HTTPS, localhost, or the tested offline edition.';return}resetResult();setWorking();try{if(operation==='encrypt'){const packed=await encryptFilesChunked();resultBlob=packed.blob;resultName=packed.name.replace(/\.nivora$/,'.ciphersplat');$('#resultTitle').textContent='Encryption complete';$('#resultMeta').textContent=`${formatBytes(resultBlob.size)} · ${packed.count} item(s) · Argon2id + AES-256-GCM`;$('#copyResult').classList.add('hidden')}else{const first=new Uint8Array(await selectedEntries[0].file.slice(0,8).arrayBuffer()),chunked=MAGIC2.every((b,i)=>first[i]===b)||MAGIC3.every((b,i)=>first[i]===b)||MAGIC4.every((b,i)=>first[i]===b);if(!chunked){if(operation==='verify')throw Error('Integrity-check mode requires a CipherSplat chunked package.');return run()}const unlocked=await decryptFilesChunked(selectedEntries[0].file);if(operation==='verify'){resultBlob=null;resultFiles=[];$('#resultTitle').textContent='CipherSplat package integrity confirmed';$('#resultMeta').textContent=`${unlocked.files.length} item(s) · every record authenticated · plaintext discarded`;$('#downloadResult').classList.add('hidden')}else{if(unlocked.meta.kind==='folder'){resultFiles=unlocked.files;resultBlob=new Blob([]);$('#downloadResult').textContent='Restore folder ↓'}else{resultBlob=unlocked.files[0].blob;resultName=unlocked.files[0].path}$('#resultTitle').textContent='Vault unlocked safely';$('#resultMeta').textContent=`${unlocked.files.length} item(s) · every chunk authenticated`}$('#copyResult').classList.add('hidden')}progress(100,'Complete — authentication verified');setStatusChip('SECURED','secured');$('#statusTitle').textContent=operation==='verify'?'Package integrity confirmed':operation==='encrypt'?'Vault sealed':'Vault authenticated';$('#statusCopy').textContent=operation==='verify'?'Every encrypted record was authenticated; restored plaintext was discarded.':'Processed in bounded chunks. No data left this device.';els.result.classList.remove('hidden')}catch(e){els.error.textContent=e.message||String(e);setStatusChip('STOPPED');$('#statusTitle').textContent='Vault operation failed';$('#statusCopy').textContent='No output was created. Check the input and password.';progress(0,'Operation stopped')}finally{sync()}}
const encryptFilesChunkedUnchecked=encryptFilesChunked;
encryptFilesChunked=async function(){if(!selectedEntries.length||selectedEntries.length>MAX_FILES)throw Error('Select between 1 and 100,000 files per package.');let records=1;for(const entry of selectedEntries){const normalized=entry.path.replace(/\\/g,'/');if(!safePath(normalized))throw Error(`Unsafe or unsupported filename: ${entry.file.name}`);records+=Math.ceil(entry.file.size/CHUNK_SIZE);if(records>MAX_RECORDS)throw Error('The selection contains too many chunks for one package.')}const packed=await encryptFilesChunkedUnchecked();packed.name=packed.name.replace(/\.nivora$/,PACKAGE_EXTENSION);return packed};
new MutationObserver(()=>{if(!els.result.classList.contains('hidden')){els.password.value='';els.password.type='password';els.text.value='';$('#charCount').textContent='0';clearSelection();updateStrength();sync();requestAnimationFrame(()=>els.result.focus())}}).observe(els.result,{attributes:true,attributeFilter:['class']});
const GENERATOR_WORDS='amber anchor apple april arbor arrow atlas autumn badge bamboo basil beach berry birch bloom breeze brook brush cabin cactus canyon cedar cherry circle cloud clover coral cosmos creek crystal dawn delta dune eagle earth ember falcon fern field flame forest fox frost garden gem glacier globe grape grove harbor hazel hill honey horizon island ivy jade juniper lake lantern leaf lemon light lilac lotus maple meadow melon mint moon moss mountain nectar night north ocean olive onyx orange orbit orchid owl palm pearl pebble pine plum pond poppy prism quartz rain raven reef river robin rose sage sand scarlet sea shadow shell shore silver sky snow solar sparrow spring spruce star stone storm stream summer sun sunset surf swan teal thunder tide tiger topaz trail tree tulip valley violet wave wheat willow wind winter wren acorn alpine aspen aurora beacon bluebird bronze butterfly cardinal cascade chestnut cinnamon comet copper cricket daisy drift elm emerald feather finch firefly galaxy garnet ginger granite heron hibiscus indigo iris jasmine lagoon lark lavender lightning lily lime magnolia mango mandarin marigold marsh mercury midnight mist morning oak oasis opal otter peach pinecone planet prairie pumpkin rainbow redwood ripple sapphire savanna sequoia skylark starlight sunflower thyme timber twilight walnut waterfall wildflower woodland'.split(' ');
const generator=$('#passwordGenerator'),generated=$('#generatedPassword'),generatorError=$('#generatorError');
function secureIndex(max){if(!Number.isSafeInteger(max)||max<1||max>0x100000000)throw Error('Invalid random selection range.');const ceiling=Math.floor(0x100000000/max)*max,bytes=new Uint32Array(1);do{crypto.getRandomValues(bytes)}while(bytes[0]>=ceiling);return bytes[0]%max}
function securePick(source){return source[secureIndex(source.length)]}
function secureShuffle(values){for(let i=values.length-1;i>0;i--){const j=secureIndex(i+1);[values[i],values[j]]=[values[j],values[i]]}return values}
function passwordGeneratorMode(){return generator.querySelector('input[name="generatorMode"]:checked').value}
function generatePasswordValue(){const sets=[];if($('#includeUpper').checked)sets.push('ABCDEFGHJKLMNPQRSTUVWXYZ');if($('#includeLower').checked)sets.push('abcdefghijkmnopqrstuvwxyz');if($('#includeNumbers').checked)sets.push('23456789');if($('#includeSymbols').checked)sets.push('!@#$%&*+-=?');if(!sets.length)throw Error('Choose at least one character type.');const length=Number($('#passwordLength').value),all=sets.join(''),result=sets.map(securePick);while(result.length<length)result.push(securePick(all));return{value:secureShuffle(result).join(''),entropy:length*Math.log2(all.length)}}
function generatePassphraseValue(){const count=Number($('#wordCount').value),separator=$('#wordSeparator').value,capitalize=$('#capitalizeWords').checked,number=$('#includeWordNumber').checked;const words=Array.from({length:count},()=>{const word=securePick(GENERATOR_WORDS);return capitalize?word[0].toUpperCase()+word.slice(1):word});if(number)words.push(String(secureIndex(100)).padStart(2,'0'));return{value:words.join(separator),entropy:count*Math.log2(GENERATOR_WORDS.length)+(number?Math.log2(100):0)}}
function renderGenerated(){generatorError.textContent='';try{const result=passwordGeneratorMode()==='password'?generatePasswordValue():generatePassphraseValue(),bits=Math.floor(result.entropy),label=bits<45?'Weak':bits<60?'Fair':bits<80?'Strong':'Excellent';generated.textContent=result.value;$('#generatedEntropy').textContent=`${bits} bits`;$('#generatedStrength').textContent=label}catch(error){generated.textContent='';$('#generatedEntropy').textContent='—';$('#generatedStrength').textContent='Unavailable';generatorError.textContent=error.message}}
function syncGeneratorMode(){const passphrase=passwordGeneratorMode()==='passphrase';$('#passwordOptions').classList.toggle('hidden',passphrase);$('#passphraseOptions').classList.toggle('hidden',!passphrase);renderGenerated()}
function openPasswordGenerator(){generator.showModal();syncGeneratorMode();requestAnimationFrame(()=>generator.querySelector('input[name="generatorMode"]:checked').focus())}
generator.querySelectorAll('input[name="generatorMode"]').forEach(input=>input.addEventListener('change',syncGeneratorMode));
['passwordLength','wordCount'].forEach(id=>$('#'+id).addEventListener('input',event=>{$('#'+id+'Value').textContent=event.target.value;renderGenerated()}));
['includeUpper','includeLower','includeNumbers','includeSymbols','wordSeparator','capitalizeWords','includeWordNumber'].forEach(id=>$('#'+id).addEventListener('change',renderGenerated));
$('#regeneratePassword').addEventListener('click',renderGenerated);
$('#copyGenerated').addEventListener('click',async event=>{try{await navigator.clipboard.writeText(generated.textContent);event.currentTarget.textContent='Copied ✓';setTimeout(()=>event.currentTarget.textContent='Copy',1500)}catch{generatorError.textContent='Clipboard access was blocked. Select and copy the password manually.'}});
$('#useGenerated').addEventListener('click',()=>{if(!generated.textContent)return;els.password.value=generated.textContent;generated.textContent='';els.password.type='text';updateStrength();sync();generator.close();els.password.focus()});
generator.addEventListener('click',event=>{if(event.target===generator)generator.close()});
generator.addEventListener('close',()=>{generated.textContent='';generatorError.textContent=''});
const offlineEdition=location.protocol==='file:'||new URLSearchParams(location.search).get('offline')==='1';
function setInternetLinks(enabled){document.documentElement.classList.toggle('offline-edition',offlineEdition);$('#connectionLabel').textContent=`Internet Connection — ${enabled?'ON':'OFF'}`;document.querySelectorAll('.online-link').forEach(link=>{if(enabled){link.href=link.dataset.onlineHref;link.removeAttribute('aria-disabled');link.tabIndex=0}else{link.removeAttribute('href');link.setAttribute('aria-disabled','true');link.tabIndex=-1}})}
if(offlineEdition){$('#offlineControls').classList.remove('hidden');$('#internetToggle').checked=false;setInternetLinks(false);$('#internetToggle').addEventListener('change',event=>setInternetLinks(event.target.checked))}
const IDLE_CLEAR_MS=10*60*1000;
let idleTimer;
function scheduleIdleClear(){clearTimeout(idleTimer);idleTimer=setTimeout(()=>{if($('.status-panel').getAttribute('aria-busy')==='true'){scheduleIdleClear();return}clearSensitiveMaterial('Cleared after 10 minutes of inactivity')},IDLE_CLEAR_MS)}
function clearSensitiveMaterial(reason='Workspace cleared'){els.password.value='';els.password.type='password';els.text.value='';$('#charCount').textContent='0';clearPgpCredentials();clearSelection();generated.textContent='';resetResult();setStatusChip('LOCKED','locked');$('#statusTitle').textContent='Sensitive material cleared';$('#statusCopy').textContent=`${reason}. Re-enter the password or key to continue.`;progress(0,reason);sync()}
['pointerdown','keydown','input','change'].forEach(type=>document.addEventListener(type,scheduleIdleClear,{passive:true}));
document.addEventListener('visibilitychange',()=>{if(!document.hidden)scheduleIdleClear()});
scheduleIdleClear();
sync();
