diff --git a/app/api.js b/app/api.js
new file mode 100644
index 00000000..69c7216c
--- /dev/null
+++ b/app/api.js
@@ -0,0 +1,206 @@
+import { arrayToB64, b64ToArray } from './utils';
+
+function post(obj) {
+  return {
+    method: 'POST',
+    headers: new Headers({
+      'Content-Type': 'application/json'
+    }),
+    body: JSON.stringify(obj)
+  };
+}
+
+function parseNonce(header) {
+  header = header || '';
+  return header.split(' ')[1];
+}
+
+async function fetchWithAuth(url, params, keychain) {
+  const result = {};
+  params = params || {};
+  const h = await keychain.authHeader();
+  params.headers = new Headers({ Authorization: h });
+  const response = await fetch(url, params);
+  result.response = response;
+  result.ok = response.ok;
+  const nonce = parseNonce(response.headers.get('WWW-Authenticate'));
+  result.shouldRetry = response.status === 401 && nonce !== keychain.nonce;
+  keychain.nonce = nonce;
+  return result;
+}
+
+async function fetchWithAuthAndRetry(url, params, keychain) {
+  const result = await fetchWithAuth(url, params, keychain);
+  if (result.shouldRetry) {
+    return fetchWithAuth(url, params, keychain);
+  }
+  return result;
+}
+
+export async function del(id, owner_token) {
+  const response = await fetch(`/api/delete/${id}`, post({ owner_token }));
+  return response.ok;
+}
+
+export async function setParams(id, owner_token, params) {
+  const response = await fetch(
+    `/api/params/${id}`,
+    post({
+      owner_token,
+      dlimit: params.dlimit
+    })
+  );
+  return response.ok;
+}
+
+export async function metadata(id, keychain) {
+  const result = await fetchWithAuthAndRetry(
+    `/api/metadata/${id}`,
+    { method: 'GET' },
+    keychain
+  );
+  if (result.ok) {
+    const data = await result.response.json();
+    const meta = await keychain.decryptMetadata(b64ToArray(data.metadata));
+    return {
+      dtotal: data.dtotal,
+      dlimit: data.dlimit,
+      size: data.size,
+      ttl: data.ttl,
+      iv: meta.iv,
+      name: meta.name,
+      type: meta.type
+    };
+  }
+  throw new Error(result.response.status);
+}
+
+export async function setPassword(id, owner_token, keychain) {
+  const auth = await keychain.authKeyB64();
+  const response = await fetch(
+    `/api/password/${id}`,
+    post({ owner_token, auth })
+  );
+  return response.ok;
+}
+
+export function uploadFile(encrypted, metadata, verifierB64, keychain) {
+  const xhr = new XMLHttpRequest();
+  const upload = {
+    onprogress: function() {},
+    cancel: function() {
+      xhr.abort();
+    },
+    result: new Promise(function(resolve, reject) {
+      xhr.addEventListener('loadend', function() {
+        const authHeader = xhr.getResponseHeader('WWW-Authenticate');
+        if (authHeader) {
+          keychain.nonce = parseNonce(authHeader);
+        }
+        if (xhr.status === 200) {
+          const responseObj = JSON.parse(xhr.responseText);
+          return resolve({
+            url: responseObj.url,
+            id: responseObj.id,
+            ownerToken: responseObj.owner
+          });
+        }
+        reject(new Error(xhr.status));
+      });
+    })
+  };
+  const dataView = new DataView(encrypted);
+  const blob = new Blob([dataView], { type: 'application/octet-stream' });
+  const fd = new FormData();
+  fd.append('data', blob);
+  xhr.upload.addEventListener('progress', function(event) {
+    if (event.lengthComputable) {
+      upload.onprogress([event.loaded, event.total]);
+    }
+  });
+  xhr.open('post', '/api/upload', true);
+  xhr.setRequestHeader('X-File-Metadata', arrayToB64(new Uint8Array(metadata)));
+  xhr.setRequestHeader('Authorization', `send-v1 ${verifierB64}`);
+  xhr.send(fd);
+  return upload;
+}
+
+function download(id, keychain) {
+  const xhr = new XMLHttpRequest();
+  const download = {
+    onprogress: function() {},
+    cancel: function() {
+      xhr.abort();
+    },
+    result: new Promise(async function(resolve, reject) {
+      xhr.addEventListener('loadend', function() {
+        const authHeader = xhr.getResponseHeader('WWW-Authenticate');
+        if (authHeader) {
+          keychain.nonce = parseNonce(authHeader);
+        }
+        if (xhr.status === 404) {
+          return reject(new Error('notfound'));
+        }
+        if (xhr.status !== 200) {
+          return reject(new Error(xhr.status));
+        }
+
+        const blob = new Blob([xhr.response]);
+        const fileReader = new FileReader();
+        fileReader.readAsArrayBuffer(blob);
+        fileReader.onload = function() {
+          resolve(this.result);
+        };
+      });
+      xhr.addEventListener('progress', function(event) {
+        if (event.lengthComputable && event.target.status === 200) {
+          download.onprogress([event.loaded, event.total]);
+        }
+      });
+      const auth = await keychain.authHeader();
+      xhr.open('get', `/api/download/${id}`);
+      xhr.setRequestHeader('Authorization', auth);
+      xhr.responseType = 'blob';
+      xhr.send();
+    })
+  };
+
+  return download;
+}
+
+async function tryDownload(id, keychain, onprogress, tries = 1) {
+  const dl = download(id, keychain);
+  dl.onprogress = onprogress;
+  try {
+    const result = await dl.result;
+    return result;
+  } catch (e) {
+    if (e.message === '401' && --tries > 0) {
+      return tryDownload(id, keychain, onprogress, tries);
+    }
+    throw e;
+  }
+}
+
+export function downloadFile(id, keychain) {
+  let cancelled = false;
+  function updateProgress(p) {
+    if (cancelled) {
+      // This is a bit of a hack
+      // We piggyback off of the progress event as a chance to cancel.
+      // Otherwise wiring the xhr abort up while allowing retries
+      // gets pretty nasty.
+      // 'this' here is the object returned by download(id, keychain)
+      return this.cancel();
+    }
+    dl.onprogress(p);
+  }
+  const dl = {
+    onprogress: function() {},
+    cancel: function() {
+      cancelled = true;
+    },
+    result: tryDownload(id, keychain, updateProgress, 2)
+  };
+  return dl;
+}
diff --git a/app/fileManager.js b/app/fileManager.js
index acbe9161..9be93215 100644
--- a/app/fileManager.js
+++ b/app/fileManager.js
@@ -1,51 +1,15 @@
-/* global EXPIRE_SECONDS */
 import FileSender from './fileSender';
 import FileReceiver from './fileReceiver';
-import { copyToClipboard, delay, fadeOut, percent } from './utils';
+import {
+  copyToClipboard,
+  delay,
+  fadeOut,
+  openLinksInNewTab,
+  percent,
+  saveFile
+} from './utils';
 import * as metrics from './metrics';
 
-function saveFile(file) {
-  const dataView = new DataView(file.plaintext);
-  const blob = new Blob([dataView], { type: file.type });
-  const downloadUrl = URL.createObjectURL(blob);
-
-  if (window.navigator.msSaveBlob) {
-    return window.navigator.msSaveBlob(blob, file.name);
-  }
-  const a = document.createElement('a');
-  a.href = downloadUrl;
-  a.download = file.name;
-  document.body.appendChild(a);
-  a.click();
-  URL.revokeObjectURL(downloadUrl);
-}
-
-function openLinksInNewTab(links, should = true) {
-  links = links || Array.from(document.querySelectorAll('a:not([target])'));
-  if (should) {
-    links.forEach(l => {
-      l.setAttribute('target', '_blank');
-      l.setAttribute('rel', 'noopener noreferrer');
-    });
-  } else {
-    links.forEach(l => {
-      l.removeAttribute('target');
-      l.removeAttribute('rel');
-    });
-  }
-  return links;
-}
-
-async function getDLCounts(file) {
-  const url = `/api/metadata/${file.id}`;
-  const receiver = new FileReceiver(url, file);
-  try {
-    await receiver.getMetadata(file.nonce);
-    return receiver.file;
-  } catch (e) {
-    if (e.message === '404') return false;
-  }
-}
 export default function(state, emitter) {
   let lastRender = 0;
   let updateTitle = false;
@@ -60,14 +24,11 @@ export default function(state, emitter) {
     for (const file of files) {
       const oldLimit = file.dlimit;
       const oldTotal = file.dtotal;
-      const receivedFile = await getDLCounts(file);
-      if (!receivedFile) {
+      await file.updateDownloadCount();
+      if (file.dtotal === file.dlimit) {
         state.storage.remove(file.id);
         rerender = true;
-      } else if (
-        oldLimit !== receivedFile.dlimit ||
-        oldTotal !== receivedFile.dtotal
-      ) {
+      } else if (oldLimit !== file.dlimit || oldTotal !== file.dtotal) {
         rerender = true;
       }
     }
@@ -92,16 +53,15 @@ export default function(state, emitter) {
     checkFiles();
   });
 
-  emitter.on('navigate', checkFiles);
+  // emitter.on('navigate', checkFiles);
 
   emitter.on('render', () => {
     lastRender = Date.now();
   });
 
   emitter.on('changeLimit', async ({ file, value }) => {
-    await FileSender.changeLimit(file.id, file.ownerToken, value);
-    file.dlimit = value;
-    state.storage.writeFiles();
+    await file.changeLimit(value);
+    state.storage.writeFile(file);
     metrics.changedDownloadLimit(file);
   });
 
@@ -116,11 +76,10 @@ export default function(state, emitter) {
         location
       });
       state.storage.remove(file.id);
-      await FileSender.delete(file.id, file.ownerToken);
+      await file.del();
     } catch (e) {
       state.raven.captureException(e);
     }
-    state.fileInfo = null;
   });
 
   emitter.on('cancel', () => {
@@ -134,32 +93,24 @@ export default function(state, emitter) {
     sender.on('encrypting', render);
     state.transfer = sender;
     render();
+
     const links = openLinksInNewTab();
     await delay(200);
     try {
-      const start = Date.now();
       metrics.startedUpload({ size, type });
-      const info = await sender.upload();
-      const time = Date.now() - start;
-      const speed = size / (time / 1000);
-      metrics.completedUpload({ size, time, speed, type });
+      const ownedFile = await sender.upload(state.storage);
+      state.storage.totalUploads += 1;
+      metrics.completedUpload(ownedFile);
+
+      state.storage.addFile(ownedFile);
+
       document.getElementById('cancel-upload').hidden = 'hidden';
       await delay(1000);
       await fadeOut('upload-progress');
-      info.name = file.name;
-      info.size = size;
-      info.type = type;
-      info.time = time;
-      info.speed = speed;
-      info.createdAt = Date.now();
-      info.url = `${info.url}#${info.secretKey}`;
-      info.expiresAt = Date.now() + EXPIRE_SECONDS * 1000;
-      state.fileInfo = info;
-      state.storage.addFile(state.fileInfo);
       openLinksInNewTab(links, false);
       state.transfer = null;
-      state.storage.totalUploads += 1;
-      emitter.emit('pushState', `/share/${info.id}`);
+
+      emitter.emit('pushState', `/share/${ownedFile.id}`);
     } catch (err) {
       console.error(err);
       state.transfer = null;
@@ -174,31 +125,29 @@ export default function(state, emitter) {
     }
   });
 
-  emitter.on('password', async ({ existingPassword, password, file }) => {
+  emitter.on('password', async ({ password, file }) => {
     try {
-      await FileSender.setPassword(existingPassword, password, file);
+      await file.setPassword(password);
+      state.storage.writeFile(file);
       metrics.addedPassword({ size: file.size });
-      file.password = password;
-      state.storage.writeFiles();
-    } catch (e) {
-      console.error(e);
+    } catch (err) {
+      console.error(err);
     }
     render();
   });
 
-  emitter.on('preview', async () => {
+  emitter.on('getMetadata', async () => {
     const file = state.fileInfo;
-    const url = `/api/download/${file.id}`;
-    const receiver = new FileReceiver(url, file);
-    receiver.on('progress', updateProgress);
-    receiver.on('decrypting', render);
-    state.transfer = receiver;
+    const receiver = new FileReceiver(file);
     try {
-      await receiver.getMetadata(file.nonce);
+      await receiver.getMetadata();
+      receiver.on('progress', updateProgress);
+      receiver.on('decrypting', render);
+      state.transfer = receiver;
     } catch (e) {
       if (e.message === '401') {
         file.password = null;
-        if (!file.pwd) {
+        if (!file.requiresPassword) {
           return emitter.emit('pushState', '/404');
         }
       }
@@ -214,7 +163,7 @@ export default function(state, emitter) {
     try {
       const start = Date.now();
       metrics.startedDownload({ size: file.size, ttl: file.ttl });
-      const f = await state.transfer.download(file.nonce);
+      const f = await state.transfer.download();
       const time = Date.now() - start;
       const speed = size / (time / 1000);
       await delay(1000);
@@ -225,8 +174,11 @@ export default function(state, emitter) {
       metrics.completedDownload({ size, time, speed });
       emitter.emit('pushState', '/completed');
     } catch (err) {
+      if (err.message === '0') {
+        // download cancelled
+        return render();
+      }
       console.error(err);
-      // TODO cancelled download
       const location = err.message === 'notfound' ? '/404' : '/error';
       if (location === '/error') {
         state.raven.captureException(err);
@@ -244,6 +196,14 @@ export default function(state, emitter) {
     metrics.copiedLink({ location });
   });
 
+  setInterval(() => {
+    // poll for updates of the download counts
+    // TODO something for the share page: || state.route === '/share/:id'
+    if (state.route === '/') {
+      checkFiles();
+    }
+  }, 2 * 60 * 1000);
+
   setInterval(() => {
     // poll for rerendering the file list countdown timers
     if (
diff --git a/app/fileReceiver.js b/app/fileReceiver.js
index 0766f71e..03347dcd 100644
--- a/app/fileReceiver.js
+++ b/app/fileReceiver.js
@@ -1,104 +1,21 @@
 import Nanobus from 'nanobus';
-import { arrayToB64, b64ToArray, bytes } from './utils';
+import Keychain from './keychain';
+import { bytes } from './utils';
+import { metadata, downloadFile } from './api';
 
 export default class FileReceiver extends Nanobus {
-  constructor(url, file) {
+  constructor(fileInfo) {
     super('FileReceiver');
-    this.secretKeyPromise = window.crypto.subtle.importKey(
-      'raw',
-      b64ToArray(file.secretKey),
-      'HKDF',
-      false,
-      ['deriveKey']
-    );
-    this.encryptKeyPromise = this.secretKeyPromise.then(sk => {
-      const encoder = new TextEncoder();
-      return window.crypto.subtle.deriveKey(
-        {
-          name: 'HKDF',
-          salt: new Uint8Array(),
-          info: encoder.encode('encryption'),
-          hash: 'SHA-256'
-        },
-        sk,
-        {
-          name: 'AES-GCM',
-          length: 128
-        },
-        false,
-        ['decrypt']
-      );
-    });
-    if (file.pwd) {
-      const encoder = new TextEncoder();
-      this.authKeyPromise = window.crypto.subtle
-        .importKey(
-          'raw',
-          encoder.encode(file.password),
-          { name: 'PBKDF2' },
-          false,
-          ['deriveKey']
-        )
-        .then(pwdKey =>
-          window.crypto.subtle.deriveKey(
-            {
-              name: 'PBKDF2',
-              salt: encoder.encode(file.url),
-              iterations: 100,
-              hash: 'SHA-256'
-            },
-            pwdKey,
-            {
-              name: 'HMAC',
-              hash: 'SHA-256'
-            },
-            true,
-            ['sign']
-          )
-        );
-    } else {
-      this.authKeyPromise = this.secretKeyPromise.then(sk => {
-        const encoder = new TextEncoder();
-        return window.crypto.subtle.deriveKey(
-          {
-            name: 'HKDF',
-            salt: new Uint8Array(),
-            info: encoder.encode('authentication'),
-            hash: 'SHA-256'
-          },
-          sk,
-          {
-            name: 'HMAC',
-            hash: { name: 'SHA-256' }
-          },
-          false,
-          ['sign']
-        );
-      });
+    this.keychain = new Keychain(fileInfo.secretKey, fileInfo.nonce);
+    if (fileInfo.requiresPassword) {
+      this.keychain.setPassword(fileInfo.password, fileInfo.url);
     }
-    this.metaKeyPromise = this.secretKeyPromise.then(sk => {
-      const encoder = new TextEncoder();
-      return window.crypto.subtle.deriveKey(
-        {
-          name: 'HKDF',
-          salt: new Uint8Array(),
-          info: encoder.encode('metadata'),
-          hash: 'SHA-256'
-        },
-        sk,
-        {
-          name: 'AES-GCM',
-          length: 128
-        },
-        false,
-        ['decrypt']
-      );
-    });
-    this.file = file;
-    this.url = url;
+    this.fileInfo = fileInfo;
+    this.fileDownload = null;
     this.msg = 'fileSizeProgress';
     this.state = 'initialized';
     this.progress = [0, 1];
+    this.cancelled = false;
   }
 
   get progressRatio() {
@@ -113,160 +30,51 @@ export default class FileReceiver extends Nanobus {
   }
 
   cancel() {
-    // TODO
-  }
-
-  async fetchMetadata(nonce) {
-    const authHeader = await this.getAuthHeader(nonce);
-    return new Promise((resolve, reject) => {
-      const xhr = new XMLHttpRequest();
-      xhr.onreadystatechange = () => {
-        if (xhr.readyState === XMLHttpRequest.DONE) {
-          if (xhr.status === 404) {
-            return reject(new Error(xhr.status));
-          }
-          const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
-          this.file.nonce = nonce;
-          if (xhr.status === 200) {
-            return resolve(xhr.response);
-          }
-          const err = new Error(xhr.status);
-          err.nonce = nonce;
-          reject(err);
-        }
-      };
-      xhr.onerror = () => reject(new Error(0));
-      xhr.ontimeout = () => reject(new Error(0));
-      xhr.open('get', `/api/metadata/${this.file.id}`);
-      xhr.setRequestHeader('Authorization', authHeader);
-      xhr.responseType = 'json';
-      xhr.timeout = 2000;
-      xhr.send();
-    });
-  }
-
-  async getMetadata(nonce) {
-    let data = null;
-    try {
-      try {
-        data = await this.fetchMetadata(nonce);
-      } catch (e) {
-        if (e.message === '401' && nonce !== e.nonce) {
-          // allow one retry for changed nonce
-          data = await this.fetchMetadata(e.nonce);
-        } else {
-          throw e;
-        }
-      }
-      const metaKey = await this.metaKeyPromise;
-      const json = await window.crypto.subtle.decrypt(
-        {
-          name: 'AES-GCM',
-          iv: new Uint8Array(12),
-          tagLength: 128
-        },
-        metaKey,
-        b64ToArray(data.metadata)
-      );
-      const decoder = new TextDecoder();
-      const meta = JSON.parse(decoder.decode(json));
-      this.file.name = meta.name;
-      this.file.type = meta.type;
-      this.file.iv = meta.iv;
-      this.file.size = data.size;
-      this.file.ttl = data.ttl;
-      this.file.dlimit = data.dlimit;
-      this.file.dtotal = data.dtotal;
-      this.state = 'ready';
-    } catch (e) {
-      this.state = 'invalid';
-      throw e;
+    this.cancelled = true;
+    if (this.fileDownload) {
+      this.fileDownload.cancel();
     }
   }
 
-  async downloadFile(nonce) {
-    const authHeader = await this.getAuthHeader(nonce);
-    return new Promise((resolve, reject) => {
-      const xhr = new XMLHttpRequest();
-
-      xhr.onprogress = event => {
-        if (event.lengthComputable && event.target.status !== 404) {
-          this.progress = [event.loaded, event.total];
-          this.emit('progress', this.progress);
-        }
-      };
-
-      xhr.onload = event => {
-        if (xhr.status === 404) {
-          reject(new Error('notfound'));
-          return;
-        }
-        if (xhr.status !== 200) {
-          const err = new Error(xhr.status);
-          err.nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
-          return reject(err);
-        }
-
-        const blob = new Blob([xhr.response]);
-        const fileReader = new FileReader();
-        fileReader.onload = function() {
-          resolve(this.result);
-        };
-
-        fileReader.readAsArrayBuffer(blob);
-      };
-
-      xhr.open('get', this.url);
-      xhr.setRequestHeader('Authorization', authHeader);
-      xhr.responseType = 'blob';
-      xhr.send();
-    });
+  async getMetadata() {
+    const meta = await metadata(this.fileInfo.id, this.keychain);
+    if (meta) {
+      this.keychain.setIV(meta.iv);
+      this.fileInfo.name = meta.name;
+      this.fileInfo.type = meta.type;
+      this.fileInfo.iv = meta.iv;
+      this.fileInfo.size = meta.size;
+      this.state = 'ready';
+      return;
+    }
+    this.state = 'invalid';
+    return;
   }
 
-  async getAuthHeader(nonce) {
-    const authKey = await this.authKeyPromise;
-    const sig = await window.crypto.subtle.sign(
-      {
-        name: 'HMAC'
-      },
-      authKey,
-      b64ToArray(nonce)
-    );
-    return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
-  }
-
-  async download(nonce) {
+  async download() {
     this.state = 'downloading';
     this.emit('progress', this.progress);
     try {
-      const encryptKey = await this.encryptKeyPromise;
-      let ciphertext = null;
-      try {
-        ciphertext = await this.downloadFile(nonce);
-      } catch (e) {
-        if (e.message === '401' && nonce !== e.nonce) {
-          ciphertext = await this.downloadFile(e.nonce);
-        } else {
-          throw e;
-        }
-      }
+      const download = await downloadFile(this.fileInfo.id, this.keychain);
+      download.onprogress = p => {
+        this.progress = p;
+        this.emit('progress', p);
+      };
+      this.fileDownload = download;
+      const ciphertext = await download.result;
+      this.fileDownload = null;
       this.msg = 'decryptingFile';
       this.emit('decrypting');
-      const plaintext = await window.crypto.subtle.decrypt(
-        {
-          name: 'AES-GCM',
-          iv: b64ToArray(this.file.iv),
-          tagLength: 128
-        },
-        encryptKey,
-        ciphertext
-      );
+      const plaintext = await this.keychain.decryptFile(ciphertext);
+      if (this.cancelled) {
+        throw new Error(0);
+      }
       this.msg = 'downloadFinish';
       this.state = 'complete';
       return {
         plaintext,
-        name: decodeURIComponent(this.file.name),
-        type: this.file.type
+        name: decodeURIComponent(this.fileInfo.name),
+        type: this.fileInfo.type
       };
     } catch (e) {
       this.state = 'invalid';
diff --git a/app/fileSender.js b/app/fileSender.js
index 3965edf5..fd6465c1 100644
--- a/app/fileSender.js
+++ b/app/fileSender.js
@@ -1,97 +1,19 @@
+/* global EXPIRE_SECONDS */
 import Nanobus from 'nanobus';
-import { arrayToB64, b64ToArray, bytes } from './utils';
-
-async function getAuthHeader(authKey, nonce) {
-  const sig = await window.crypto.subtle.sign(
-    {
-      name: 'HMAC'
-    },
-    authKey,
-    b64ToArray(nonce)
-  );
-  return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
-}
-
-async function sendPassword(file, authKey, rawAuth) {
-  const authHeader = await getAuthHeader(authKey, file.nonce);
-  return new Promise((resolve, reject) => {
-    const xhr = new XMLHttpRequest();
-    xhr.onreadystatechange = () => {
-      if (xhr.readyState === XMLHttpRequest.DONE) {
-        if (xhr.status === 200) {
-          const nonce = xhr.getResponseHeader('WWW-Authenticate').split(' ')[1];
-          file.nonce = nonce;
-          return resolve(xhr.response);
-        }
-        reject(new Error(xhr.status));
-      }
-    };
-    xhr.onerror = () => reject(new Error(0));
-    xhr.ontimeout = () => reject(new Error(0));
-    xhr.open('post', `/api/password/${file.id}`);
-    xhr.setRequestHeader('Authorization', authHeader);
-    xhr.setRequestHeader('Content-Type', 'application/json');
-    xhr.responseType = 'json';
-    xhr.timeout = 2000;
-    xhr.send(JSON.stringify({ auth: arrayToB64(new Uint8Array(rawAuth)) }));
-  });
-}
+import OwnedFile from './ownedFile';
+import Keychain from './keychain';
+import { arrayToB64, bytes } from './utils';
+import { uploadFile } from './api';
 
 export default class FileSender extends Nanobus {
   constructor(file) {
     super('FileSender');
     this.file = file;
+    this.uploadRequest = null;
     this.msg = 'importingFile';
     this.progress = [0, 1];
     this.cancelled = false;
-    this.iv = window.crypto.getRandomValues(new Uint8Array(12));
-    this.uploadXHR = new XMLHttpRequest();
-    this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
-    this.secretKey = window.crypto.subtle.importKey(
-      'raw',
-      this.rawSecret,
-      'HKDF',
-      false,
-      ['deriveKey']
-    );
-  }
-
-  static delete(id, token) {
-    return new Promise((resolve, reject) => {
-      if (!id || !token) {
-        return reject();
-      }
-      const xhr = new XMLHttpRequest();
-      xhr.open('POST', `/api/delete/${id}`);
-      xhr.setRequestHeader('Content-Type', 'application/json');
-
-      xhr.onreadystatechange = () => {
-        if (xhr.readyState === XMLHttpRequest.DONE) {
-          resolve();
-        }
-      };
-
-      xhr.send(JSON.stringify({ owner_token: token }));
-    });
-  }
-
-  static changeLimit(id, owner_token, dlimit) {
-    return new Promise((resolve, reject) => {
-      if (!id || !owner_token) {
-        return reject();
-      }
-      const xhr = new XMLHttpRequest();
-      xhr.open('POST', `/api/params/${id}`);
-      xhr.setRequestHeader('Content-Type', 'application/json');
-
-      xhr.onreadystatechange = () => {
-        if (xhr.readyState === XMLHttpRequest.DONE) {
-          resolve();
-        }
-      };
-
-      xhr.send(JSON.stringify({ owner_token, dlimit }));
-    });
+    this.keychain = new Keychain();
   }
 
   get progressRatio() {
@@ -107,8 +29,8 @@ export default class FileSender extends Nanobus {
 
   cancel() {
     this.cancelled = true;
-    if (this.msg === 'fileSizeProgress') {
-      this.uploadXHR.abort();
+    if (this.uploadRequest) {
+      this.uploadRequest.cancel();
     }
   }
 
@@ -116,6 +38,7 @@ export default class FileSender extends Nanobus {
     return new Promise((resolve, reject) => {
       const reader = new FileReader();
       reader.readAsArrayBuffer(this.file);
+      // TODO: progress?
       reader.onload = function(event) {
         const plaintext = new Uint8Array(this.result);
         resolve(plaintext);
@@ -126,218 +49,60 @@ export default class FileSender extends Nanobus {
     });
   }
 
-  uploadFile(encrypted, metadata, rawAuth) {
-    return new Promise((resolve, reject) => {
-      const dataView = new DataView(encrypted);
-      const blob = new Blob([dataView], { type: 'application/octet-stream' });
-      const fd = new FormData();
-      fd.append('data', blob);
-
-      const xhr = this.uploadXHR;
-
-      xhr.upload.addEventListener('progress', e => {
-        if (e.lengthComputable) {
-          this.progress = [e.loaded, e.total];
-          this.emit('progress', this.progress);
-        }
-      });
-
-      xhr.onreadystatechange = () => {
-        if (xhr.readyState === XMLHttpRequest.DONE) {
-          if (xhr.status === 200) {
-            const nonce = xhr
-              .getResponseHeader('WWW-Authenticate')
-              .split(' ')[1];
-            this.progress = [1, 1];
-            this.msg = 'notifyUploadDone';
-            const responseObj = JSON.parse(xhr.responseText);
-            return resolve({
-              url: responseObj.url,
-              id: responseObj.id,
-              secretKey: arrayToB64(this.rawSecret),
-              ownerToken: responseObj.owner,
-              nonce
-            });
-          }
-          this.msg = 'errorPageHeader';
-          reject(new Error(xhr.status));
-        }
-      };
-
-      xhr.open('post', '/api/upload', true);
-      xhr.setRequestHeader(
-        'X-File-Metadata',
-        arrayToB64(new Uint8Array(metadata))
-      );
-      xhr.setRequestHeader('Authorization', `send-v1 ${arrayToB64(rawAuth)}`);
-      xhr.send(fd);
-      this.msg = 'fileSizeProgress';
-    });
-  }
-
-  async upload() {
-    const encoder = new TextEncoder();
-    const secretKey = await this.secretKey;
-    const encryptKey = await window.crypto.subtle.deriveKey(
-      {
-        name: 'HKDF',
-        salt: new Uint8Array(),
-        info: encoder.encode('encryption'),
-        hash: 'SHA-256'
-      },
-      secretKey,
-      {
-        name: 'AES-GCM',
-        length: 128
-      },
-      false,
-      ['encrypt']
-    );
-    const authKey = await window.crypto.subtle.deriveKey(
-      {
-        name: 'HKDF',
-        salt: new Uint8Array(),
-        info: encoder.encode('authentication'),
-        hash: 'SHA-256'
-      },
-      secretKey,
-      {
-        name: 'HMAC',
-        hash: 'SHA-256'
-      },
-      true,
-      ['sign']
-    );
-    const metaKey = await window.crypto.subtle.deriveKey(
-      {
-        name: 'HKDF',
-        salt: new Uint8Array(),
-        info: encoder.encode('metadata'),
-        hash: 'SHA-256'
-      },
-      secretKey,
-      {
-        name: 'AES-GCM',
-        length: 128
-      },
-      false,
-      ['encrypt']
-    );
+  async upload(storage) {
+    const start = Date.now();
     const plaintext = await this.readFile();
     if (this.cancelled) {
       throw new Error(0);
     }
     this.msg = 'encryptingFile';
     this.emit('encrypting');
-    const encrypted = await window.crypto.subtle.encrypt(
-      {
-        name: 'AES-GCM',
-        iv: this.iv,
-        tagLength: 128
-      },
-      encryptKey,
-      plaintext
-    );
-    const metadata = await window.crypto.subtle.encrypt(
-      {
-        name: 'AES-GCM',
-        iv: new Uint8Array(12),
-        tagLength: 128
-      },
-      metaKey,
-      encoder.encode(
-        JSON.stringify({
-          iv: arrayToB64(this.iv),
-          name: this.file.name,
-          type: this.file.type || 'application/octet-stream'
-        })
-      )
-    );
-    const rawAuth = await window.crypto.subtle.exportKey('raw', authKey);
+    const encrypted = await this.keychain.encryptFile(plaintext);
+    const metadata = await this.keychain.encryptMetadata(this.file);
+    const authKeyB64 = await this.keychain.authKeyB64();
     if (this.cancelled) {
       throw new Error(0);
     }
-    return this.uploadFile(encrypted, metadata, new Uint8Array(rawAuth));
-  }
-
-  static async setPassword(existingPassword, password, file) {
-    const encoder = new TextEncoder();
-    const secretKey = await window.crypto.subtle.importKey(
-      'raw',
-      b64ToArray(file.secretKey),
-      'HKDF',
-      false,
-      ['deriveKey']
+    this.uploadRequest = uploadFile(
+      encrypted,
+      metadata,
+      authKeyB64,
+      this.keychain
     );
-    const authKey = await window.crypto.subtle.deriveKey(
-      {
-        name: 'HKDF',
-        salt: new Uint8Array(),
-        info: encoder.encode('authentication'),
-        hash: 'SHA-256'
-      },
-      secretKey,
-      {
-        name: 'HMAC',
-        hash: 'SHA-256'
-      },
-      true,
-      ['sign']
-    );
-    const pwdKey = await window.crypto.subtle.importKey(
-      'raw',
-      encoder.encode(password),
-      { name: 'PBKDF2' },
-      false,
-      ['deriveKey']
-    );
-    const oldPwdkey = await window.crypto.subtle.importKey(
-      'raw',
-      encoder.encode(existingPassword),
-      { name: 'PBKDF2' },
-      false,
-      ['deriveKey']
-    );
-    const oldAuthKey = await window.crypto.subtle.deriveKey(
-      {
-        name: 'PBKDF2',
-        salt: encoder.encode(file.url),
-        iterations: 100,
-        hash: 'SHA-256'
-      },
-      oldPwdkey,
-      {
-        name: 'HMAC',
-        hash: 'SHA-256'
-      },
-      true,
-      ['sign']
-    );
-    const newAuthKey = await window.crypto.subtle.deriveKey(
-      {
-        name: 'PBKDF2',
-        salt: encoder.encode(file.url),
-        iterations: 100,
-        hash: 'SHA-256'
-      },
-      pwdKey,
-      {
-        name: 'HMAC',
-        hash: 'SHA-256'
-      },
-      true,
-      ['sign']
-    );
-    const rawAuth = await window.crypto.subtle.exportKey('raw', newAuthKey);
-    const aKey = existingPassword ? oldAuthKey : authKey;
+    this.msg = 'fileSizeProgress';
+    this.uploadRequest.onprogress = p => {
+      this.progress = p;
+      this.emit('progress', p);
+    };
     try {
-      await sendPassword(file, aKey, rawAuth);
+      const result = await this.uploadRequest.result;
+      const time = Date.now() - start;
+      this.msg = 'notifyUploadDone';
+      this.uploadRequest = null;
+      this.progress = [1, 1];
+      const secretKey = arrayToB64(this.keychain.rawSecret);
+      const ownedFile = new OwnedFile(
+        {
+          id: result.id,
+          url: `${result.url}#${secretKey}`,
+          name: this.file.name,
+          size: this.file.size,
+          type: this.file.type, //TODO 'click' ?
+          time: time,
+          speed: this.file.size / (time / 1000),
+          createdAt: Date.now(),
+          expiresAt: Date.now() + EXPIRE_SECONDS * 1000,
+          secretKey: secretKey,
+          nonce: this.keychain.nonce,
+          ownerToken: result.ownerToken
+        },
+        storage
+      );
+      return ownedFile;
     } catch (e) {
-      if (e.message === '401' && file.nonce !== e.nonce) {
-        await sendPassword(file, aKey, rawAuth);
-      } else {
-        throw e;
-      }
+      this.msg = 'errorPageHeader';
+      this.uploadRequest = null;
+      throw e;
     }
   }
 }
diff --git a/app/keychain.js b/app/keychain.js
new file mode 100644
index 00000000..bb19864b
--- /dev/null
+++ b/app/keychain.js
@@ -0,0 +1,212 @@
+import Nanobus from 'nanobus';
+import { arrayToB64, b64ToArray } from './utils';
+
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+
+export default class Keychain extends Nanobus {
+  constructor(secretKeyB64, nonce, ivB64) {
+    super('Keychain');
+    this._nonce = nonce || 'yRCdyQ1EMSA3mo4rqSkuNQ==';
+    if (ivB64) {
+      this.iv = b64ToArray(ivB64);
+    } else {
+      this.iv = window.crypto.getRandomValues(new Uint8Array(12));
+    }
+    if (secretKeyB64) {
+      this.rawSecret = b64ToArray(secretKeyB64);
+    } else {
+      this.rawSecret = window.crypto.getRandomValues(new Uint8Array(16));
+    }
+    this.secretKeyPromise = window.crypto.subtle.importKey(
+      'raw',
+      this.rawSecret,
+      'HKDF',
+      false,
+      ['deriveKey']
+    );
+    this.encryptKeyPromise = this.secretKeyPromise.then(function(secretKey) {
+      return window.crypto.subtle.deriveKey(
+        {
+          name: 'HKDF',
+          salt: new Uint8Array(),
+          info: encoder.encode('encryption'),
+          hash: 'SHA-256'
+        },
+        secretKey,
+        {
+          name: 'AES-GCM',
+          length: 128
+        },
+        false,
+        ['encrypt', 'decrypt']
+      );
+    });
+    this.metaKeyPromise = this.secretKeyPromise.then(function(secretKey) {
+      return window.crypto.subtle.deriveKey(
+        {
+          name: 'HKDF',
+          salt: new Uint8Array(),
+          info: encoder.encode('metadata'),
+          hash: 'SHA-256'
+        },
+        secretKey,
+        {
+          name: 'AES-GCM',
+          length: 128
+        },
+        false,
+        ['encrypt', 'decrypt']
+      );
+    });
+    this.authKeyPromise = this.secretKeyPromise.then(function(secretKey) {
+      return window.crypto.subtle.deriveKey(
+        {
+          name: 'HKDF',
+          salt: new Uint8Array(),
+          info: encoder.encode('authentication'),
+          hash: 'SHA-256'
+        },
+        secretKey,
+        {
+          name: 'HMAC',
+          hash: { name: 'SHA-256' }
+        },
+        true,
+        ['sign']
+      );
+    });
+  }
+
+  get nonce() {
+    return this._nonce;
+  }
+
+  set nonce(n) {
+    if (n !== this.nonce) {
+      this.emit('nonceChanged', n);
+    }
+    this._nonce = n;
+  }
+
+  setIV(ivB64) {
+    this.iv = b64ToArray(ivB64);
+  }
+
+  setPassword(password, shareUrl) {
+    this.authKeyPromise = window.crypto.subtle
+      .importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, [
+        'deriveKey'
+      ])
+      .then(passwordKey =>
+        window.crypto.subtle.deriveKey(
+          {
+            name: 'PBKDF2',
+            salt: encoder.encode(shareUrl),
+            iterations: 100,
+            hash: 'SHA-256'
+          },
+          passwordKey,
+          {
+            name: 'HMAC',
+            hash: 'SHA-256'
+          },
+          true,
+          ['sign']
+        )
+      );
+  }
+
+  setAuthKey(authKeyB64) {
+    this.authKeyPromise = window.crypto.subtle.importKey(
+      'raw',
+      b64ToArray(authKeyB64),
+      {
+        name: 'HMAC',
+        hash: 'SHA-256'
+      },
+      true,
+      ['sign']
+    );
+  }
+
+  async authKeyB64() {
+    const authKey = await this.authKeyPromise;
+    const rawAuth = await window.crypto.subtle.exportKey('raw', authKey);
+    return arrayToB64(new Uint8Array(rawAuth));
+  }
+
+  async authHeader() {
+    const authKey = await this.authKeyPromise;
+    const sig = await window.crypto.subtle.sign(
+      {
+        name: 'HMAC'
+      },
+      authKey,
+      b64ToArray(this.nonce)
+    );
+    return `send-v1 ${arrayToB64(new Uint8Array(sig))}`;
+  }
+
+  async encryptFile(plaintext) {
+    const encryptKey = await this.encryptKeyPromise;
+    const ciphertext = await window.crypto.subtle.encrypt(
+      {
+        name: 'AES-GCM',
+        iv: this.iv,
+        tagLength: 128
+      },
+      encryptKey,
+      plaintext
+    );
+    return ciphertext;
+  }
+
+  async encryptMetadata(metadata) {
+    const metaKey = await this.metaKeyPromise;
+    const ciphertext = await window.crypto.subtle.encrypt(
+      {
+        name: 'AES-GCM',
+        iv: new Uint8Array(12),
+        tagLength: 128
+      },
+      metaKey,
+      encoder.encode(
+        JSON.stringify({
+          iv: arrayToB64(this.iv),
+          name: metadata.name,
+          type: metadata.type || 'application/octet-stream'
+        })
+      )
+    );
+    return ciphertext;
+  }
+
+  async decryptFile(ciphertext) {
+    const encryptKey = await this.encryptKeyPromise;
+    const plaintext = await window.crypto.subtle.decrypt(
+      {
+        name: 'AES-GCM',
+        iv: this.iv,
+        tagLength: 128
+      },
+      encryptKey,
+      ciphertext
+    );
+    return plaintext;
+  }
+
+  async decryptMetadata(ciphertext) {
+    const metaKey = await this.metaKeyPromise;
+    const plaintext = await window.crypto.subtle.decrypt(
+      {
+        name: 'AES-GCM',
+        iv: new Uint8Array(12),
+        tagLength: 128
+      },
+      metaKey,
+      ciphertext
+    );
+    return JSON.parse(decoder.decode(plaintext));
+  }
+}
diff --git a/app/main.js b/app/main.js
index 7f7e0402..0b41b75d 100644
--- a/app/main.js
+++ b/app/main.js
@@ -15,30 +15,34 @@ if (navigator.doNotTrack !== '1' && window.RAVEN_CONFIG) {
 }
 
 app.use((state, emitter) => {
-  // init state
   state.transfer = null;
   state.fileInfo = null;
   state.translate = locale.getTranslator();
   state.storage = storage;
   state.raven = Raven;
-  emitter.on('DOMContentLoaded', async () => {
-    let reason = null;
+  window.appState = state;
+  emitter.on('DOMContentLoaded', async function checkSupport() {
+    let unsupportedReason = null;
     if (
+      // Firefox < 50
       /firefox/i.test(navigator.userAgent) &&
-      parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) <=
-        49
+      parseInt(navigator.userAgent.match(/firefox\/*([^\n\r]*)\./i)[1], 10) < 50
     ) {
-      reason = 'outdated';
+      unsupportedReason = 'outdated';
     }
     if (/edge\/\d+/i.test(navigator.userAgent)) {
-      reason = 'edge';
+      unsupportedReason = 'edge';
     }
     const ok = await canHasSend(assets.get('cryptofill.js'));
     if (!ok) {
-      reason = /firefox/i.test(navigator.userAgent) ? 'outdated' : 'gcm';
+      unsupportedReason = /firefox/i.test(navigator.userAgent)
+        ? 'outdated'
+        : 'gcm';
     }
-    if (reason) {
-      setTimeout(() => emitter.emit('replaceState', `/unsupported/${reason}`));
+    if (unsupportedReason) {
+      setTimeout(() =>
+        emitter.emit('replaceState', `/unsupported/${unsupportedReason}`)
+      );
     }
   });
 });
diff --git a/app/ownedFile.js b/app/ownedFile.js
new file mode 100644
index 00000000..b482caf8
--- /dev/null
+++ b/app/ownedFile.js
@@ -0,0 +1,81 @@
+import Keychain from './keychain';
+import { arrayToB64 } from './utils';
+import { del, metadata, setParams, setPassword } from './api';
+
+export default class OwnedFile {
+  constructor(obj, storage) {
+    this.id = obj.id;
+    this.url = obj.url;
+    this.name = obj.name;
+    this.size = obj.size;
+    this.type = obj.type;
+    this.time = obj.time;
+    this.speed = obj.speed;
+    this.createdAt = obj.createdAt;
+    this.expiresAt = obj.expiresAt;
+    this.ownerToken = obj.ownerToken;
+    this.dlimit = obj.dlimit || 1;
+    this.dtotal = obj.dtotal || 0;
+    this.keychain = new Keychain(obj.secretKey, obj.nonce);
+    this.keychain.on('nonceChanged', () => storage.writeFile(this));
+    if (obj.authKeyB64) {
+      this.authKeyB64 = obj.authKeyB64;
+      this.keychain.setAuthKey(obj.authKeyB64);
+    }
+  }
+
+  async setPassword(password) {
+    this.password = password;
+    this.keychain.setPassword(password, this.url);
+    const result = await setPassword(this.id, this.ownerToken, this.keychain);
+    this.authKeyB64 = await this.keychain.authKeyB64();
+    return result;
+  }
+
+  del() {
+    return del(this.id, this.ownerToken);
+  }
+
+  changeLimit(dlimit) {
+    if (this.dlimit !== dlimit) {
+      this.dlimit = dlimit;
+      return setParams(this.id, this.ownerToken, { dlimit });
+    }
+    return Promise.resolve(true);
+  }
+
+  hasPassword() {
+    return !!this.authKeyB64;
+  }
+
+  async updateDownloadCount() {
+    try {
+      const result = await metadata(this.id, this.keychain);
+      this.dtotal = result.dtotal;
+    } catch (e) {
+      if (e.message === '404') {
+        this.dtotal = this.dlimit;
+      }
+    }
+  }
+
+  toJSON() {
+    return {
+      id: this.id,
+      url: this.url,
+      name: this.name,
+      size: this.size,
+      type: this.type,
+      time: this.time,
+      speed: this.speed,
+      createdAt: this.createdAt,
+      expiresAt: this.expiresAt,
+      secretKey: arrayToB64(this.keychain.rawSecret),
+      nonce: this.keychain.nonce,
+      ownerToken: this.ownerToken,
+      dlimit: this.dlimit,
+      dtotal: this.dtotal,
+      authKeyB64: this.authKeyB64
+    };
+  }
+}
diff --git a/app/routes/download.js b/app/routes/download.js
index c0a47a4c..ca0f2b71 100644
--- a/app/routes/download.js
+++ b/app/routes/download.js
@@ -1,12 +1,60 @@
 const preview = require('../templates/preview');
 const download = require('../templates/download');
+const notFound = require('../templates/notFound');
+const downloadPassword = require('../templates/downloadPassword');
+const downloadButton = require('../templates/downloadButton');
+
+function hasFileInfo() {
+  return !!document.getElementById('dl-file');
+}
+
+function getFileInfoFromDOM() {
+  const el = document.getElementById('dl-file');
+  if (!el) {
+    return null;
+  }
+  return {
+    nonce: el.getAttribute('data-nonce'),
+    requiresPassword: !!+el.getAttribute('data-requires-password')
+  };
+}
+
+function createFileInfo(state) {
+  const metadata = getFileInfoFromDOM();
+  return {
+    id: state.params.id,
+    secretKey: state.params.key,
+    nonce: metadata.nonce,
+    requiresPassword: metadata.requiresPassword
+  };
+}
 
 module.exports = function(state, emit) {
+  if (!state.fileInfo) {
+    // This is a fresh page load
+    // We need to parse the file info from the server's html
+    if (!hasFileInfo()) {
+      return notFound(state, emit);
+    }
+    state.fileInfo = createFileInfo(state);
+
+    if (!state.fileInfo.requiresPassword) {
+      emit('getMetadata');
+    }
+  }
+
+  let pageAction = ''; //default state: we don't have file metadata
   if (state.transfer) {
     const s = state.transfer.state;
     if (s === 'downloading' || s === 'complete') {
+      // Downloading is in progress
       return download(state, emit);
     }
+    // we have file metadata
+    pageAction = downloadButton(state, emit);
+  } else if (state.fileInfo.requiresPassword && !state.fileInfo.password) {
+    // we're waiting on the user for a valid password
+    pageAction = downloadPassword(state, emit);
   }
-  return preview(state, emit);
+  return preview(state, pageAction);
 };
diff --git a/app/routes/home.js b/app/routes/home.js
index 0059ceb0..2be53047 100644
--- a/app/routes/home.js
+++ b/app/routes/home.js
@@ -2,8 +2,7 @@ const welcome = require('../templates/welcome');
 const upload = require('../templates/upload');
 
 module.exports = function(state, emit) {
-  if (state.transfer && state.transfer.iv) {
-    //TODO relying on 'iv' is gross
+  if (state.transfer) {
     return upload(state, emit);
   }
   return welcome(state, emit);
diff --git a/app/routes/index.js b/app/routes/index.js
index 4bf6a55a..dfe6c34c 100644
--- a/app/routes/index.js
+++ b/app/routes/index.js
@@ -7,26 +7,33 @@ const fxPromo = require('../templates/fxPromo');
 
 const app = choo();
 
-function showBanner(state) {
-  return state.promo && !state.route.startsWith('/unsupported/');
+function banner(state, emit) {
+  if (state.promo && !state.route.startsWith('/unsupported/')) {
+    return fxPromo(state, emit);
+  }
 }
 
 function body(template) {
   return function(state, emit) {
     const b = html`<body>
-      ${showBanner(state) ? fxPromo(state, emit) : ''}
+      ${banner(state, emit)}
       ${header(state)}
       <div class="all">
         <noscript>
-          <h2>Firefox Send requires JavaScript</h2>
-          <p><a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-does-firefox-send-require-javascript">Why does Firefox Send require JavaScript?</a></p>
-          <p>Please enable JavaScript and try again.</p>
+          <h2>${state.translate('javascriptRequired')}</h2>
+          <p>
+            <a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-does-firefox-send-require-javascript">
+            ${state.translate('whyJavascript')}
+            </a>
+          </p>
+          <p>${state.translate('enableJavascript')}</p>
         </noscript>
         ${template(state, emit)}
       </div>
       ${footer(state)}
     </body>`;
     if (state.layout) {
+      // server side only
       return state.layout(state, b);
     }
     return b;
diff --git a/app/storage.js b/app/storage.js
index 27cba1cb..64d591fc 100644
--- a/app/storage.js
+++ b/app/storage.js
@@ -1,4 +1,5 @@
 import { isFile } from './utils';
+import OwnedFile from './ownedFile';
 
 class Mem {
   constructor() {
@@ -42,7 +43,7 @@ class Storage {
       const k = this.engine.key(i);
       if (isFile(k)) {
         try {
-          const f = JSON.parse(this.engine.getItem(k));
+          const f = new OwnedFile(JSON.parse(this.engine.getItem(k)), this);
           if (!f.id) {
             f.id = f.fileId;
           }
@@ -108,11 +109,15 @@ class Storage {
 
   addFile(file) {
     this._files.push(file);
+    this.writeFile(file);
+  }
+
+  writeFile(file) {
     this.engine.setItem(file.id, JSON.stringify(file));
   }
 
   writeFiles() {
-    this._files.forEach(f => this.engine.setItem(f.id, JSON.stringify(f)));
+    this._files.forEach(f => this.writeFile(f));
   }
 }
 
diff --git a/app/templates/blank.js b/app/templates/blank.js
index 080a3232..ec104ac3 100644
--- a/app/templates/blank.js
+++ b/app/templates/blank.js
@@ -1,6 +1,6 @@
 const html = require('choo/html');
 
 module.exports = function() {
-  const div = html`<div id="page-one"></div>`;
+  const div = html`<div></div>`;
   return div;
 };
diff --git a/app/templates/completed.js b/app/templates/completed.js
index 6415a0c0..88bb9958 100644
--- a/app/templates/completed.js
+++ b/app/templates/completed.js
@@ -5,21 +5,22 @@ const { fadeOut } = require('../utils');
 module.exports = function(state, emit) {
   const div = html`
   <div id="page-one">
-  <div id="download" class="fadeIn">
-    <div id="download-progress">
-      <div id="dl-title" class="title">${state.translate(
-        'downloadFinish'
-      )}</div>
-      <div class="description"></div>
-      ${progress(1)}
-      <div class="upload">
-        <div class="progress-text"></div>
+    <div id="download" class="fadeIn">
+      <div id="download-progress">
+        <div id="dl-title" class="title">
+          ${state.translate('downloadFinish')}
+        </div>
+        <div class="description"></div>
+        ${progress(1)}
+        <div class="upload">
+          <div class="progress-text"></div>
+        </div>
       </div>
+      <a class="send-new"
+        data-state="completed"
+        href="/"
+        onclick=${sendNew}>${state.translate('sendYourFilesLink')}</a>
     </div>
-    <a class="send-new" data-state="completed" href="/" onclick=${
-      sendNew
-    }>${state.translate('sendYourFilesLink')}</a>
-  </div>
   </div>
   `;
 
diff --git a/app/templates/download.js b/app/templates/download.js
index eca55893..8e0c81c8 100644
--- a/app/templates/download.js
+++ b/app/templates/download.js
@@ -2,7 +2,7 @@ const html = require('choo/html');
 const progress = require('./progress');
 const { bytes } = require('../utils');
 
-module.exports = function(state) {
+module.exports = function(state, emit) {
   const transfer = state.transfer;
   const div = html`
   <div id="page-one">
@@ -24,11 +24,20 @@ module.exports = function(state) {
             transfer.msg,
             transfer.sizes
           )}</div>
+          <button
+            id="cancel-upload"
+            title="${state.translate('deletePopupCancel')}"
+            onclick=${cancel}>${state.translate('deletePopupCancel')}</button>
         </div>
       </div>
     </div>
   </div>
   `;
 
+  function cancel() {
+    const btn = document.getElementById('cancel-upload');
+    btn.remove();
+    emit('cancel');
+  }
   return div;
 };
diff --git a/app/templates/downloadButton.js b/app/templates/downloadButton.js
new file mode 100644
index 00000000..be01333f
--- /dev/null
+++ b/app/templates/downloadButton.js
@@ -0,0 +1,16 @@
+const html = require('choo/html');
+
+module.exports = function(state, emit) {
+  function download(event) {
+    event.preventDefault();
+    emit('download', state.fileInfo);
+  }
+
+  return html`
+  <div>
+    <button id="download-btn"
+      class="btn"
+      onclick=${download}>${state.translate('downloadButtonLabel')}
+    </button>
+  </div>`;
+};
diff --git a/app/templates/downloadPassword.js b/app/templates/downloadPassword.js
index 4ce13608..dea30a03 100644
--- a/app/templates/downloadPassword.js
+++ b/app/templates/downloadPassword.js
@@ -5,8 +5,9 @@ module.exports = function(state, emit) {
   const label =
     fileInfo.password === null
       ? html`
-          <label class="red"
-            for="unlock-input">${state.translate('passwordTryAgain')}</label>`
+          <label class="red" for="unlock-input">
+            ${state.translate('passwordTryAgain')}
+          </label>`
       : html`
           <label for="unlock-input">
             ${state.translate('unlockInputLabel')}
@@ -48,7 +49,7 @@ module.exports = function(state, emit) {
       document.getElementById('unlock-btn').disabled = true;
       state.fileInfo.url = window.location.href;
       state.fileInfo.password = password;
-      emit('preview');
+      emit('getMetadata');
     }
   }
 
diff --git a/app/templates/file.js b/app/templates/file.js
index 8d658ad7..8a56d090 100644
--- a/app/templates/file.js
+++ b/app/templates/file.js
@@ -1,52 +1,62 @@
 const html = require('choo/html');
 const assets = require('../../common/assets');
 
-function timeLeft(milliseconds) {
+function timeLeft(milliseconds, state) {
   const minutes = Math.floor(milliseconds / 1000 / 60);
   const hours = Math.floor(minutes / 60);
-  const seconds = Math.floor((milliseconds / 1000) % 60);
   if (hours >= 1) {
-    return `${hours}h ${minutes % 60}m`;
+    return state.translate('expiresHoursMinutes', {
+      hours,
+      minutes: minutes % 60
+    });
   } else if (hours === 0) {
-    return `${minutes}m ${seconds}s`;
+    if (minutes === 0) {
+      return state.translate('expiresMinutes', { minutes: '< 1' });
+    }
+    return state.translate('expiresMinutes', { minutes });
   }
   return null;
 }
 
 module.exports = function(file, state, emit) {
   const ttl = file.expiresAt - Date.now();
-  const remainingTime = timeLeft(ttl) || state.translate('linkExpiredAlt');
+  const remainingTime =
+    timeLeft(ttl, state) || state.translate('linkExpiredAlt');
   const downloadLimit = file.dlimit || 1;
   const totalDownloads = file.dtotal || 0;
   const row = html`
   <tr id="${file.id}">
-    <td class="overflow-col" title="${
-      file.name
-    }"><a class="link" href="/share/${file.id}">${file.name}</a></td>
+    <td class="overflow-col" title="${file.name}">
+      <a class="link" href="/share/${file.id}">${file.name}</a>
+    </td>
     <td class="center-col">
-      <img onclick=${copyClick} src="${assets.get(
-    'copy-16.svg'
-  )}" class="icon-copy" title="${state.translate('copyUrlHover')}">
-      <span class="text-copied" hidden="true">${state.translate(
-        'copiedUrl'
-      )}</span>
+      <img
+        onclick=${copyClick}
+        src="${assets.get('copy-16.svg')}"
+        class="icon-copy"
+        title="${state.translate('copyUrlHover')}">
+      <span class="text-copied" hidden="true">
+        ${state.translate('copiedUrl')}
+      </span>
     </td>
     <td>${remainingTime}</td>
-    <td class="center-col">${totalDownloads}/${downloadLimit}</td>
+    <td class="center-col">${totalDownloads} / ${downloadLimit}</td>
     <td class="center-col">
-      <img onclick=${showPopup} src="${assets.get(
-    'close-16.svg'
-  )}" class="icon-delete" title="${state.translate('deleteButtonHover')}">
+      <img
+        onclick=${showPopup}
+        src="${assets.get('close-16.svg')}"
+        class="icon-delete"
+        title="${state.translate('deleteButtonHover')}">
       <div class="popup">
         <div class="popuptext" onblur=${cancel} tabindex="-1">
           <div class="popup-message">${state.translate('deletePopupText')}</div>
           <div class="popup-action">
-            <span class="popup-no" onclick=${cancel}>${state.translate(
-    'deletePopupCancel'
-  )}</span>
-            <span class="popup-yes" onclick=${deleteFile}>${state.translate(
-    'deletePopupYes'
-  )}</span>
+            <span class="popup-no" onclick=${cancel}>
+              ${state.translate('deletePopupCancel')}
+            </span>
+            <span class="popup-yes" onclick=${deleteFile}>
+              ${state.translate('deletePopupYes')}
+            </span>
           </div>
         </div>
       </div>
diff --git a/app/templates/fileList.js b/app/templates/fileList.js
index 6e63e31a..ece4ebcf 100644
--- a/app/templates/fileList.js
+++ b/app/templates/fileList.js
@@ -9,18 +9,18 @@ module.exports = function(state, emit) {
       <thead>
         <tr>
           <th id="uploaded-file">${state.translate('uploadedFile')}</th>
-          <th id="copy-file-list" class="center-col">${state.translate(
-            'copyFileList'
-          )}</th>
-          <th id="expiry-time-file-list" >${state.translate(
-            'timeFileList'
-          )}</th>
-          <th id="expiry-downloads-file-list" >${state.translate(
-            'downloadsFileList'
-          )}</th>
-          <th id="delete-file-list" class="center-col">${state.translate(
-            'deleteFileList'
-          )}</th>
+          <th id="copy-file-list" class="center-col">
+            ${state.translate('copyFileList')}
+          </th>
+          <th id="expiry-time-file-list" >
+            ${state.translate('timeFileList')}
+          </th>
+          <th id="expiry-downloads-file-list" >
+            ${state.translate('downloadsFileList')}
+          </th>
+          <th id="delete-file-list" class="center-col">
+            ${state.translate('deleteFileList')}
+          </th>
         </tr>
       </thead>
       <tbody>
diff --git a/app/templates/footer.js b/app/templates/footer.js
index 96925b04..a2dfe276 100644
--- a/app/templates/footer.js
+++ b/app/templates/footer.js
@@ -4,31 +4,40 @@ const assets = require('../../common/assets');
 module.exports = function(state) {
   return html`<div class="footer">
     <div class="legal-links">
-      <a href="https://www.mozilla.org" role="presentation"><img class="mozilla-logo" src="${assets.get(
-        'mozilla-logo.svg'
-      )}" alt="mozilla"/></a>
-      <a href="https://www.mozilla.org/about/legal">${state.translate(
-        'footerLinkLegal'
-      )}</a>
-      <a href="https://testpilot.firefox.com/about">${state.translate(
-        'footerLinkAbout'
-      )}</a>
+      <a href="https://www.mozilla.org" role="presentation">
+        <img
+          class="mozilla-logo"
+          src="${assets.get('mozilla-logo.svg')}"
+          alt="mozilla"/>
+      </a>
+      <a href="https://www.mozilla.org/about/legal">
+        ${state.translate('footerLinkLegal')}
+      </a>
+      <a href="https://testpilot.firefox.com/about">
+        ${state.translate('footerLinkAbout')}
+      </a>
       <a href="/legal">${state.translate('footerLinkPrivacy')}</a>
       <a href="/legal">${state.translate('footerLinkTerms')}</a>
-      <a href="https://www.mozilla.org/privacy/websites/#cookies">${state.translate(
-        'footerLinkCookies'
-      )}</a>
-      <a href="https://www.mozilla.org/about/legal/report-infringement/">${state.translate(
-        'reportIPInfringement'
-      )}</a>
+      <a href="https://www.mozilla.org/privacy/websites/#cookies">
+        ${state.translate('footerLinkCookies')}
+      </a>
+      <a href="https://www.mozilla.org/about/legal/report-infringement/">
+        ${state.translate('reportIPInfringement')}
+      </a>
     </div>
     <div class="social-links">
-      <a href="https://github.com/mozilla/send" role="presentation"><img class="github" src="${assets.get(
-        'github-icon.svg'
-      )}" alt="github"/></a>
-      <a href="https://twitter.com/FxTestPilot" role="presentation"><img class="twitter" src="${assets.get(
-        'twitter-icon.svg'
-      )}" alt="twitter"/></a>
+      <a href="https://github.com/mozilla/send" role="presentation">
+        <img
+          class="github"
+          src="${assets.get('github-icon.svg')}"
+          alt="github"/>
+      </a>
+      <a href="https://twitter.com/FxTestPilot" role="presentation">
+        <img
+          class="twitter"
+          src="${assets.get('twitter-icon.svg')}"
+          alt="twitter"/>
+      </a>
     </div>
   </div>`;
 };
diff --git a/app/templates/header.js b/app/templates/header.js
index 3c0e1bb0..761b8188 100644
--- a/app/templates/header.js
+++ b/app/templates/header.js
@@ -41,9 +41,10 @@ module.exports = function(state) {
   return html`<header class="header">
   <div class="send-logo">
     <a href="/">
-      <img src="${assets.get(
-        'send_logo.svg'
-      )}" alt="Send"/><h1 class="site-title">Send</h1>
+      <img
+        src="${assets.get('send_logo.svg')}"
+        alt="Send"/>
+      <h1 class="site-title">Send</h1>
     </a>
     <div class="site-subtitle">
       <a href="https://testpilot.firefox.com">Firefox Test Pilot</a>
diff --git a/app/templates/notFound.js b/app/templates/notFound.js
index c29bf194..495b8b08 100644
--- a/app/templates/notFound.js
+++ b/app/templates/notFound.js
@@ -9,12 +9,12 @@ module.exports = function(state) {
       <div class="share-window">
         <img src="${assets.get('illustration_expired.svg')}" id="expired-img">
       </div>
-      <div class="expired-description">${state.translate(
-        'uploadPageExplainer'
-      )}</div>
-      <a class="send-new" href="/" data-state="notfound">${state.translate(
-        'sendYourFilesLink'
-      )}</a>
+      <div class="expired-description">
+        ${state.translate('uploadPageExplainer')}
+      </div>
+      <a class="send-new" href="/" data-state="notfound">
+        ${state.translate('sendYourFilesLink')}
+      </a>
     </div>
   </div>`;
   return div;
diff --git a/app/templates/preview.js b/app/templates/preview.js
index 63b1ae2a..5916780d 100644
--- a/app/templates/preview.js
+++ b/app/templates/preview.js
@@ -1,48 +1,13 @@
 const html = require('choo/html');
 const assets = require('../../common/assets');
-const notFound = require('./notFound');
-const downloadPassword = require('./downloadPassword');
 const { bytes } = require('../utils');
 
-function getFileFromDOM() {
-  const el = document.getElementById('dl-file');
-  if (!el) {
-    return null;
-  }
-  return {
-    nonce: el.getAttribute('data-nonce'),
-    pwd: !!+el.getAttribute('data-requires-password')
-  };
-}
-
-module.exports = function(state, emit) {
-  state.fileInfo = state.fileInfo || getFileFromDOM();
-  if (!state.fileInfo) {
-    return notFound(state, emit);
-  }
-  state.fileInfo.id = state.params.id;
-  state.fileInfo.secretKey = state.params.key;
+module.exports = function(state, pageAction) {
   const fileInfo = state.fileInfo;
   const size = fileInfo.size
     ? state.translate('downloadFileSize', { size: bytes(fileInfo.size) })
     : '';
-  let action = html`
-  <div>
-    <img src="${assets.get('illustration_download.svg')}"
-      id="download-img"
-      alt="${state.translate('downloadAltText')}"/>
-    <div>
-      <button id="download-btn"
-        class="btn"
-        onclick=${download}>${state.translate('downloadButtonLabel')}
-      </button>
-    </div>
-  </div>`;
-  if (fileInfo.pwd && !fileInfo.password) {
-    action = downloadPassword(state, emit);
-  } else if (!state.transfer) {
-    emit('preview');
-  }
+
   const title = fileInfo.name
     ? state.translate('downloadFileName', { filename: fileInfo.name })
     : state.translate('downloadFileTitle');
@@ -53,20 +18,20 @@ module.exports = function(state, emit) {
         <div class="title">
           <span id="dl-file"
             data-nonce="${fileInfo.nonce}"
-            data-requires-password="${fileInfo.pwd}">${title}</span>
+            data-requires-password="${fileInfo.requiresPassword}"
+            >${title}</span>
         <span id="dl-filesize">${' ' + size}</span>
         </div>
         <div class="description">${state.translate('downloadMessage')}</div>
-        ${action}
+        <img
+          src="${assets.get('illustration_download.svg')}"
+          id="download-img"
+          alt="${state.translate('downloadAltText')}"/>
+        ${pageAction}
       </div>
       <a class="send-new" href="/">${state.translate('sendYourFilesLink')}</a>
     </div>
   </div>
   `;
-
-  function download(event) {
-    event.preventDefault();
-    emit('download', fileInfo);
-  }
   return div;
 };
diff --git a/app/templates/progress.js b/app/templates/progress.js
index 997e05ba..fe0cf6b7 100644
--- a/app/templates/progress.js
+++ b/app/templates/progress.js
@@ -10,18 +10,30 @@ module.exports = function(progressRatio) {
   const percent = Math.floor(progressRatio * 100);
   const div = html`
   <div class="progress-bar">
-    <svg id="progress" width="${oDiameter}" height="${
-    oDiameter
-  }" viewPort="0 0 ${oDiameter} ${oDiameter}" version="1.1">
-      <circle r="${radius}" cx="${oRadius}" cy="${oRadius}" fill="transparent"/>
-      <circle id="bar" r="${radius}" cx="${oRadius}" cy="${
-    oRadius
-  }" fill="transparent" transform="rotate(-90 ${oRadius} ${
-    oRadius
-  })" stroke-dasharray="${circumference}" stroke-dashoffset="${dashOffset}"/>
-      <text class="percentage" text-anchor="middle" x="50%" y="98"><tspan class="percent-number">${
-        percent
-      }</tspan><tspan class="percent-sign">%</tspan></text>
+    <svg
+      id="progress"
+      width="${oDiameter}"
+      height="${oDiameter}"
+      viewPort="0 0 ${oDiameter} ${oDiameter}"
+      version="1.1">
+      <circle
+        r="${radius}"
+        cx="${oRadius}"
+        cy="${oRadius}"
+        fill="transparent"/>
+      <circle
+        id="bar"
+        r="${radius}"
+        cx="${oRadius}"
+        cy="${oRadius}"
+        fill="transparent"
+        transform="rotate(-90 ${oRadius} ${oRadius})"
+        stroke-dasharray="${circumference}"
+        stroke-dashoffset="${dashOffset}"/>
+      <text class="percentage" text-anchor="middle" x="50%" y="98">
+        <tspan class="percent-number">${percent}</tspan>
+        <tspan class="percent-sign">%</tspan>
+      </text>
     </svg>
   </div>
   `;
diff --git a/app/templates/selectbox.js b/app/templates/selectbox.js
index dc1290e2..be42da33 100644
--- a/app/templates/selectbox.js
+++ b/app/templates/selectbox.js
@@ -47,9 +47,7 @@ module.exports = function(selected, options, translate, changed) {
       <ul id="${id}" class="selectOptions">
         ${options.map(
           i =>
-            html`<li class="selectOption" onclick=${choose} data-value="${i}">${
-              i
-            }</li>`
+            html`<li class="selectOption" onclick=${choose} data-value="${i}">${i}</li>`
         )}
       </ul>
     </div>`;
diff --git a/app/templates/share.js b/app/templates/share.js
index 2aa31230..1ee8b4ba 100644
--- a/app/templates/share.js
+++ b/app/templates/share.js
@@ -2,34 +2,11 @@
 const html = require('choo/html');
 const assets = require('../../common/assets');
 const notFound = require('./notFound');
-const uploadPassword = require('./uploadPassword');
+const uploadPasswordSet = require('./uploadPasswordSet');
+const uploadPasswordUnset = require('./uploadPasswordUnset');
 const selectbox = require('./selectbox');
 const { allowedCopy, delay, fadeOut } = require('../utils');
 
-function inputChanged() {
-  const resetInput = document.getElementById('unlock-reset-input');
-  const resetBtn = document.getElementById('unlock-reset-btn');
-  if (resetInput.value.length > 0) {
-    resetBtn.classList.remove('btn-hidden');
-    resetInput.classList.remove('input-no-btn');
-  } else {
-    resetBtn.classList.add('btn-hidden');
-    resetInput.classList.add('input-no-btn');
-  }
-}
-
-function toggleResetInput(event) {
-  const form = event.target.parentElement.querySelector('form');
-  const input = document.getElementById('unlock-reset-input');
-  if (form.style.visibility === 'hidden' || form.style.visibility === '') {
-    form.style.visibility = 'visible';
-    input.focus();
-  } else {
-    form.style.visibility = 'hidden';
-  }
-  inputChanged();
-}
-
 function expireInfo(file, translate, emit) {
   const hours = Math.floor(EXPIRE_SECONDS / 60 / 60);
   const el = html([
@@ -55,19 +32,16 @@ module.exports = function(state, emit) {
     return notFound(state, emit);
   }
 
-  file.password = file.password || '';
-
-  const passwordSection = file.password
-    ? passwordComplete(file.password)
-    : uploadPassword(state, emit);
+  const passwordSection = file.hasPassword()
+    ? uploadPasswordSet(state, emit)
+    : uploadPasswordUnset(state, emit);
   const div = html`
   <div id="share-link" class="fadeIn">
     <div class="title">${expireInfo(file, state.translate, emit)}</div>
     <div id="share-window">
       <div id="copy-text">
-        ${state.translate('copyUrlFormLabelWithName', {
-          filename: file.name
-        })}</div>
+        ${state.translate('copyUrlFormLabelWithName', { filename: file.name })}
+      </div>
       <div id="copy">
         <input id="link" type="url" value="${file.url}" readonly="true"/>
         <button id="copy-btn"
@@ -86,13 +60,11 @@ module.exports = function(state, emit) {
            <div class="popup-message">${state.translate('deletePopupText')}
            </div>
            <div class="popup-action">
-             <span class="popup-no" onclick=${cancel}>${state.translate(
-    'deletePopupCancel'
-  )}
+             <span class="popup-no" onclick=${cancel}>
+              ${state.translate('deletePopupCancel')}
              </span>
-             <span class="popup-yes" onclick=${deleteFile}>${state.translate(
-    'deletePopupYes'
-  )}
+             <span class="popup-yes" onclick=${deleteFile}>
+              ${state.translate('deletePopupYes')}
              </span>
            </div>
          </div>
@@ -105,54 +77,6 @@ module.exports = function(state, emit) {
   </div>
   `;
 
-  function passwordComplete(password) {
-    const passwordSpan = html([
-      `<span>${state.translate('passwordResult', {
-        password:
-          '<pre class="passwordOriginal"></pre><pre class="passwordMask"></pre>'
-      })}</span>`
-    ]);
-    const og = passwordSpan.querySelector('.passwordOriginal');
-    const masked = passwordSpan.querySelector('.passwordMask');
-    og.textContent = password;
-    masked.textContent = password.replace(/./g, '●');
-    return html`<div class="selectPassword">
-      ${passwordSpan}
-      <button
-        id="resetButton"
-        onclick=${toggleResetInput}
-        >${state.translate('changePasswordButton')}</button>
-      <form
-        id='reset-form'
-        class="setPassword hidden"
-        onsubmit=${resetPassword}
-        data-no-csrf>
-        <input id="unlock-reset-input"
-          class="unlock-input input-no-btn"
-          maxlength="32"
-          autocomplete="off"
-          type="password"
-          oninput=${inputChanged}
-          placeholder="${state.translate('unlockInputPlaceholder')}">
-        <input type="submit"
-          id="unlock-reset-btn"
-          class="btn btn-hidden"
-          value="${state.translate('changePasswordButton')}"/>
-      </form>
-      </div>`;
-  }
-
-  function resetPassword(event) {
-    event.preventDefault();
-    const existingPassword = file.password;
-    const password = document.querySelector('#unlock-reset-input').value;
-    if (password.length > 0) {
-      document.getElementById('copy').classList.remove('wait-password');
-      document.getElementById('copy-btn').disabled = false;
-      emit('password', { existingPassword, password, file });
-    }
-  }
-
   function showPopup() {
     const popupText = document.querySelector('.popuptext');
     popupText.classList.add('show');
diff --git a/app/templates/unsupported.js b/app/templates/unsupported.js
index b3aa362e..03a55883 100644
--- a/app/templates/unsupported.js
+++ b/app/templates/unsupported.js
@@ -7,39 +7,45 @@ module.exports = function(state) {
       ? html`
     <div id="unsupported-browser">
       <div class="title">${state.translate('notSupportedHeader')}</div>
-      <div class="description">${state.translate(
-        'notSupportedOutdatedDetail'
-      )}</div>
-      <a id="update-firefox" href="https://support.mozilla.org/kb/update-firefox-latest-version">
-        <img src="${assets.get(
-          'firefox_logo-only.svg'
-        )}" class="firefox-logo" alt="Firefox"/>
-        <div class="unsupported-button-text">${state.translate(
-          'updateFirefox'
-        )}</div>
+      <div class="description">
+        ${state.translate('notSupportedOutdatedDetail')}
+      </div>
+      <a
+        id="update-firefox"
+        href="https://support.mozilla.org/kb/update-firefox-latest-version">
+        <img
+          src="${assets.get('firefox_logo-only.svg')}"
+          class="firefox-logo"
+          alt="Firefox"/>
+        <div class="unsupported-button-text">
+          ${state.translate('updateFirefox')}
+        </div>
       </a>
-      <div class="unsupported-description">${state.translate(
-        'uploadPageExplainer'
-      )}</div>
+      <div class="unsupported-description">
+        ${state.translate('uploadPageExplainer')}
+      </div>
     </div>`
       : html`
     <div id="unsupported-browser">
       <div class="title">${state.translate('notSupportedHeader')}</div>
       <div class="description">${state.translate('notSupportedDetail')}</div>
-      <div class="description"><a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-is-my-browser-not-supported">${state.translate(
-        'notSupportedLink'
-      )}</a></div>
+      <div class="description">
+        <a href="https://github.com/mozilla/send/blob/master/docs/faq.md#why-is-my-browser-not-supported">
+          ${state.translate('notSupportedLink')}
+        </a>
+      </div>
       <a id="dl-firefox" href="https://www.mozilla.org/firefox/new/?utm_campaign=send-acquisition&utm_medium=referral&utm_source=send.firefox.com">
-        <img src="${assets.get(
-          'firefox_logo-only.svg'
-        )}" class="firefox-logo" alt="Firefox"/>
+        <img
+          src="${assets.get('firefox_logo-only.svg')}"
+          class="firefox-logo"
+          alt="Firefox"/>
         <div class="unsupported-button-text">Firefox<br>
           <span>${state.translate('downloadFirefoxButtonSub')}</span>
         </div>
       </a>
-      <div class="unsupported-description">${state.translate(
-        'uploadPageExplainer'
-      )}</div>
+      <div class="unsupported-description">
+        ${state.translate('uploadPageExplainer')}
+      </div>
     </div>`;
   const div = html`<div id="page-one">${msg}</div>`;
   return div;
diff --git a/app/templates/upload.js b/app/templates/upload.js
index 98dc28fe..c8bb2caf 100644
--- a/app/templates/upload.js
+++ b/app/templates/upload.js
@@ -8,23 +8,24 @@ module.exports = function(state, emit) {
   const div = html`
   <div id="download">
     <div id="upload-progress" class="fadeIn">
-      <div class="title" id="upload-filename">${state.translate(
-        'uploadingPageProgress',
-        {
+      <div class="title" id="upload-filename">
+        ${state.translate('uploadingPageProgress', {
           filename: transfer.file.name,
           size: bytes(transfer.file.size)
-        }
-      )}</div>
+        })}
+      </div>
       <div class="description"></div>
       ${progress(transfer.progressRatio)}
       <div class="upload">
-        <div class="progress-text">${state.translate(
-          transfer.msg,
-          transfer.sizes
-        )}</div>
-        <button id="cancel-upload" title="${state.translate(
-          'uploadingPageCancel'
-        )}" onclick=${cancel}>${state.translate('uploadingPageCancel')}</button>
+        <div class="progress-text">
+          ${state.translate(transfer.msg, transfer.sizes)}
+        </div>
+        <button
+          id="cancel-upload"
+          title="${state.translate('uploadingPageCancel')}"
+          onclick=${cancel}>
+          ${state.translate('uploadingPageCancel')}
+        </button>
       </div>
     </div>
   </div>
diff --git a/app/templates/uploadPasswordSet.js b/app/templates/uploadPasswordSet.js
new file mode 100644
index 00000000..a8cb4fb5
--- /dev/null
+++ b/app/templates/uploadPasswordSet.js
@@ -0,0 +1,79 @@
+const html = require('choo/html');
+
+module.exports = function(state, emit) {
+  const file = state.storage.getFileById(state.params.id);
+
+  return html`<div class="selectPassword">
+    ${passwordSpan(file.password)}
+    <button
+      id="resetButton"
+      onclick=${toggleResetInput}
+      >${state.translate('changePasswordButton')}</button>
+    <form
+      id='reset-form'
+      class="setPassword hidden"
+      onsubmit=${resetPassword}
+      data-no-csrf>
+      <input id="unlock-reset-input"
+        class="unlock-input input-no-btn"
+        maxlength="32"
+        autocomplete="off"
+        type="password"
+        oninput=${inputChanged}
+        placeholder="${state.translate('unlockInputPlaceholder')}">
+      <input type="submit"
+        id="unlock-reset-btn"
+        class="btn btn-hidden"
+        value="${state.translate('changePasswordButton')}"/>
+    </form>
+    </div>`;
+
+  function passwordSpan(password) {
+    password = password || '●●●●●';
+    const span = html([
+      `<span>${state.translate('passwordResult', {
+        password:
+          '<pre class="passwordOriginal"></pre><pre class="passwordMask"></pre>'
+      })}</span>`
+    ]);
+    const og = span.querySelector('.passwordOriginal');
+    const masked = span.querySelector('.passwordMask');
+    og.textContent = password;
+    masked.textContent = password.replace(/./g, '●');
+    return span;
+  }
+
+  function inputChanged() {
+    const resetInput = document.getElementById('unlock-reset-input');
+    const resetBtn = document.getElementById('unlock-reset-btn');
+    if (resetInput.value.length > 0) {
+      resetBtn.classList.remove('btn-hidden');
+      resetInput.classList.remove('input-no-btn');
+    } else {
+      resetBtn.classList.add('btn-hidden');
+      resetInput.classList.add('input-no-btn');
+    }
+  }
+
+  function resetPassword(event) {
+    event.preventDefault();
+    const password = document.querySelector('#unlock-reset-input').value;
+    if (password.length > 0) {
+      document.getElementById('copy').classList.remove('wait-password');
+      document.getElementById('copy-btn').disabled = false;
+      emit('password', { password, file });
+    }
+  }
+
+  function toggleResetInput(event) {
+    const form = event.target.parentElement.querySelector('form');
+    const input = document.getElementById('unlock-reset-input');
+    if (form.style.visibility === 'hidden' || form.style.visibility === '') {
+      form.style.visibility = 'visible';
+      input.focus();
+    } else {
+      form.style.visibility = 'hidden';
+    }
+    inputChanged();
+  }
+};
diff --git a/app/templates/uploadPassword.js b/app/templates/uploadPasswordUnset.js
similarity index 87%
rename from app/templates/uploadPassword.js
rename to app/templates/uploadPasswordUnset.js
index d04776bb..9ddbaaba 100644
--- a/app/templates/uploadPassword.js
+++ b/app/templates/uploadPasswordUnset.js
@@ -5,9 +5,14 @@ module.exports = function(state, emit) {
   const div = html`
   <div class="selectPassword">
     <div id="addPasswordWrapper">
-      <input id="addPassword" type="checkbox" autocomplete="off" onchange=${togglePasswordInput}/>
+      <input
+        id="addPassword"
+        type="checkbox"
+        autocomplete="off"
+        onchange=${togglePasswordInput}/>
       <label for="addPassword">
-        ${state.translate('requirePasswordCheckbox')}</label>
+        ${state.translate('requirePasswordCheckbox')}
+      </label>
     </div>
     <form class="setPassword hidden" onsubmit=${setPassword} data-no-csrf>
       <input id="unlock-input"
@@ -52,12 +57,11 @@ module.exports = function(state, emit) {
 
   function setPassword(event) {
     event.preventDefault();
-    const existingPassword = null;
     const password = document.getElementById('unlock-input').value;
     if (password.length > 0) {
       document.getElementById('copy').classList.remove('wait-password');
       document.getElementById('copy-btn').disabled = false;
-      emit('password', { existingPassword, password, file });
+      emit('password', { password, file });
     }
   }
 
diff --git a/app/templates/welcome.js b/app/templates/welcome.js
index d62038c7..172f06a5 100644
--- a/app/templates/welcome.js
+++ b/app/templates/welcome.js
@@ -10,14 +10,18 @@ module.exports = function(state, emit) {
     <div class="title">${state.translate('uploadPageHeader')}</div>
     <div class="description">
       <div>${state.translate('uploadPageExplainer')}</div>
-      <a href="https://testpilot.firefox.com/experiments/send"
-        class="link">${state.translate('uploadPageLearnMore')}</a>
+      <a
+        href="https://testpilot.firefox.com/experiments/send"
+        class="link">
+        ${state.translate('uploadPageLearnMore')}
+      </a>
     </div>
     <div class="upload-window"
       ondragover=${dragover}
       ondragleave=${dragleave}>
       <div id="upload-img">
-        <img src="${assets.get('upload.svg')}"
+        <img
+          src="${assets.get('upload.svg')}"
           title="${state.translate('uploadSvgAlt')}"/>
       </div>
       <div id="upload-text">${state.translate('uploadPageDropMessage')}</div>
@@ -34,7 +38,8 @@ module.exports = function(state, emit) {
         id="browse"
         class="btn browse"
         title="${state.translate('uploadPageBrowseButton1')}">
-        ${state.translate('uploadPageBrowseButton1')}</label>
+        ${state.translate('uploadPageBrowseButton1')}
+      </label>
     </div>
     ${fileList(state, emit)}
   </div>
diff --git a/app/utils.js b/app/utils.js
index 06f480f9..9fea560c 100644
--- a/app/utils.js
+++ b/app/utils.js
@@ -15,21 +15,6 @@ function b64ToArray(str) {
   return b64.toByteArray(str);
 }
 
-function notify(str) {
-  return str;
-  /* TODO: enable once we have an opt-in ui element
-  if (!('Notification' in window)) {
-    return;
-  } else if (Notification.permission === 'granted') {
-    new Notification(str);
-  } else if (Notification.permission !== 'denied') {
-    Notification.requestPermission(function(permission) {
-      if (permission === 'granted') new Notification(str);
-    });
-  }
-  */
-}
-
 function loadShim(polyfill) {
   return new Promise((resolve, reject) => {
     const shim = document.createElement('script');
@@ -148,7 +133,37 @@ function fadeOut(id) {
   return delay(300);
 }
 
-const ONE_DAY_IN_MS = 86400000;
+function saveFile(file) {
+  const dataView = new DataView(file.plaintext);
+  const blob = new Blob([dataView], { type: file.type });
+  const downloadUrl = URL.createObjectURL(blob);
+
+  if (window.navigator.msSaveBlob) {
+    return window.navigator.msSaveBlob(blob, file.name);
+  }
+  const a = document.createElement('a');
+  a.href = downloadUrl;
+  a.download = file.name;
+  document.body.appendChild(a);
+  a.click();
+  URL.revokeObjectURL(downloadUrl);
+}
+
+function openLinksInNewTab(links, should = true) {
+  links = links || Array.from(document.querySelectorAll('a:not([target])'));
+  if (should) {
+    links.forEach(l => {
+      l.setAttribute('target', '_blank');
+      l.setAttribute('rel', 'noopener noreferrer');
+    });
+  } else {
+    links.forEach(l => {
+      l.removeAttribute('target');
+      l.removeAttribute('rel');
+    });
+  }
+  return links;
+}
 
 module.exports = {
   fadeOut,
@@ -159,8 +174,8 @@ module.exports = {
   copyToClipboard,
   arrayToB64,
   b64ToArray,
-  notify,
   canHasSend,
   isFile,
-  ONE_DAY_IN_MS
+  saveFile,
+  openLinksInNewTab
 };
diff --git a/public/locales/en-US/send.ftl b/public/locales/en-US/send.ftl
index 7cd55c53..049c498e 100644
--- a/public/locales/en-US/send.ftl
+++ b/public/locales/en-US/send.ftl
@@ -106,3 +106,10 @@ passwordTryAgain = Incorrect password. Try again.
 // This label is followed by the password needed to download a file
 passwordResult = Password: { $password }
 reportIPInfringement = Report IP Infringement
+javascriptRequired = Firefox Send requires JavaScript
+whyJavascript = Why does Firefox Send require JavaScript?
+enableJavascript = Please enable JavaScript and try again.
+// A short representation of a countdown timer containing the number of hours and minutes remaining as digits, example "13h 47m"
+expiresHoursMinutes = { $hours }h { $minutes }m
+// A short representation of a countdown timer containing the number of minutes remaining as digits, example "56m"
+expiresMinutes = { $minutes }m
\ No newline at end of file
diff --git a/server/config.js b/server/config.js
index 4bad58d8..29d25644 100644
--- a/server/config.js
+++ b/server/config.js
@@ -15,9 +15,9 @@ const conf = convict({
     env: 'REDIS_HOST'
   },
   listen_address: {
-  format: 'ipaddress',
-  default: '0.0.0.0',
-  env: 'IP_ADDRESS'
+    format: 'ipaddress',
+    default: '0.0.0.0',
+    env: 'IP_ADDRESS'
   },
   listen_port: {
     format: 'port',
diff --git a/server/prod.js b/server/prod.js
index 0515faf7..4177b160 100644
--- a/server/prod.js
+++ b/server/prod.js
@@ -24,4 +24,4 @@ app.use(
 
 app.use(pages.notfound);
 
-app.listen(config.listen_port,config.listen_address);
+app.listen(config.listen_port, config.listen_address);
diff --git a/server/routes/pages.js b/server/routes/pages.js
index d0385fbf..f7fe35f0 100644
--- a/server/routes/pages.js
+++ b/server/routes/pages.js
@@ -35,7 +35,7 @@ module.exports = {
           routes.toString(
             `/download/${req.params.id}`,
             Object.assign(state(req), {
-              fileInfo: { nonce, pwd: +pwd }
+              fileInfo: { nonce, requiresPassword: +pwd }
             })
           )
         )
diff --git a/server/routes/password.js b/server/routes/password.js
index fbb01dfa..ec861585 100644
--- a/server/routes/password.js
+++ b/server/routes/password.js
@@ -1,5 +1,4 @@
 const storage = require('../storage');
-const crypto = require('crypto');
 
 function validateID(route_id) {
   return route_id.match(/^[0-9a-fA-F]{10}$/) !== null;
@@ -10,27 +9,24 @@ module.exports = async function(req, res) {
   if (!validateID(id)) {
     return res.sendStatus(404);
   }
-  if (!req.body.auth) {
+  const ownerToken = req.body.owner_token;
+  if (!ownerToken) {
+    return res.sendStatus(404);
+  }
+  const auth = req.body.auth;
+  if (!auth) {
     return res.sendStatus(400);
   }
 
   try {
-    const auth = req.header('Authorization').split(' ')[1];
     const meta = await storage.metadata(id);
-    const hmac = crypto.createHmac('sha256', Buffer.from(meta.auth, 'base64'));
-    hmac.update(Buffer.from(meta.nonce, 'base64'));
-    const verifyHash = hmac.digest();
-    if (!verifyHash.equals(Buffer.from(auth, 'base64'))) {
-      res.set('WWW-Authenticate', `send-v1 ${meta.nonce}`);
-      return res.sendStatus(401);
+    if (meta.owner !== ownerToken) {
+      return res.sendStatus(404);
     }
+    storage.setField(id, 'auth', auth);
+    storage.setField(id, 'pwd', 1);
+    res.sendStatus(200);
   } catch (e) {
     return res.sendStatus(404);
   }
-  const nonce = crypto.randomBytes(16).toString('base64');
-  storage.setField(id, 'nonce', nonce);
-  res.set('WWW-Authenticate', `send-v1 ${nonce}`);
-  storage.setField(id, 'auth', req.body.auth);
-  storage.setField(id, 'pwd', 1);
-  res.sendStatus(200);
 };
diff --git a/test/server/server.test.js b/test/server/server.test.js
index 89a6d1ed..64402679 100644
--- a/test/server/server.test.js
+++ b/test/server/server.test.js
@@ -116,9 +116,7 @@ describe('Server integration tests', function() {
       .expect(404);
   });
 
-  it('Successfully deletes if the id is valid and the delete token matches', function(
-    done
-  ) {
+  it('Successfully deletes if the id is valid and the delete token matches', function(done) {
     request(server)
       .post('/delete/' + fileId)
       .send({ delete_token: uuid })