summaryrefslogtreecommitdiffstats
path: root/static/crypto.js
blob: 045595821f88b73c489111f1bfffd77d0ef79041 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
// NymDrop — client-side encryption via WebCrypto API.
// No external libraries. Plaintext never leaves the browser.

// Server's X25519 public key — replaced at deploy time.
const NYMDROP_PUBKEY_HEX = "6aa99a672c48cb8eee65ddd5c5e5f8947a8d52d39a8b8eb4e11b15c87fe49038";

// Required leading zero bits for the proof of work — must match the
// server's --pow-difficulty flag.
const NYMDROP_POW_DIFFICULTY = 18;

async function hexToBytes(hex) {
    const bytes = new Uint8Array(hex.length / 2);
    for (let i = 0; i < hex.length; i += 2)
        bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
    return bytes;
}

function leadingZeroBits(bytes) {
    let count = 0;
    for (const b of bytes) {
        if (b === 0) { count += 8; continue; }
        count += Math.clz32(b) - 24; // Math.clz32 counts over 32 bits, b is 8 bits
        break;
    }
    return count;
}

// Self-contained hashcash-style proof of work: no challenge round-trip with
// the server. Finds a nonce such that SHA-256("<unix-ts>:<nonce>") has at
// least `difficultyBits` leading zero bits. The server only verifies this,
// it never computes it — the cost is entirely client-side.
async function computeProofOfWork(difficultyBits) {
    const encoder = new TextEncoder();
    const ts = Math.floor(Date.now() / 1000);
    for (let nonce = 0; ; nonce++) {
        const stamp = ts + ":" + nonce;
        const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", encoder.encode(stamp)));
        if (leadingZeroBits(hash) >= difficultyBits) {
            return stamp;
        }
    }
}

async function encryptPayload(plaintext) {
    const serverPubBytes = await hexToBytes(NYMDROP_PUBKEY_HEX);

    // Generate ephemeral X25519 keypair for this submission.
    // Modern WebCrypto exposes X25519 as its own algorithm (secure-curves
    // spec), not as ECDH+namedCurve — that combination doesn't exist.
    const ephemeral = await crypto.subtle.generateKey(
        { name: "X25519" },
        true,
        ["deriveKey", "deriveBits"]
    );

    // Import server's X25519 public key.
    const serverKey = await crypto.subtle.importKey(
        "raw",
        serverPubBytes,
        { name: "X25519" },
        false,
        []
    );

    // X25519 key agreement → 32-byte shared secret.
    const sharedBits = await crypto.subtle.deriveBits(
        { name: "X25519", public: serverKey },
        ephemeral.privateKey,
        256
    );

    // Derive AES-GCM-256 key via HKDF-SHA-256.
    const hkdfKey = await crypto.subtle.importKey("raw", sharedBits, "HKDF", false, ["deriveKey"]);
    const aesKey = await crypto.subtle.deriveKey(
        {
            name: "HKDF",
            hash: "SHA-256",
            salt: new Uint8Array(32),
            info: new TextEncoder().encode("nymdrop-v1")
        },
        hkdfKey,
        { name: "AES-GCM", length: 256 },
        false,
        ["encrypt"]
    );

    // Encrypt plaintext.
    const iv = crypto.getRandomValues(new Uint8Array(12));
    const ciphertext = await crypto.subtle.encrypt(
        { name: "AES-GCM", iv },
        aesKey,
        new TextEncoder().encode(plaintext)
    );

    // Export ephemeral X25519 public key (32 bytes raw).
    const ephemeralPubRaw = await crypto.subtle.exportKey("raw", ephemeral.publicKey);

    // Packet layout: ephemeral_pub(32) + iv(12) + ciphertext
    const packet = new Uint8Array(32 + 12 + ciphertext.byteLength);
    packet.set(new Uint8Array(ephemeralPubRaw), 0);
    packet.set(iv, 32);
    packet.set(new Uint8Array(ciphertext), 44);

    return packet;
}

document.getElementById("drop-form").addEventListener("submit", async (e) => {
    e.preventDefault();
    const status = document.getElementById("status");
    status.className = "";
    status.textContent = "Encrypting...";

    try {
        let plaintext = document.getElementById("message").value;

        const fileInput = document.getElementById("file");
        if (fileInput.files.length > 0) {
            const fileBytes = await fileInput.files[0].arrayBuffer();
            const fileB64 = btoa(String.fromCharCode(...new Uint8Array(fileBytes)));
            plaintext += "\n---FILE:" + fileInput.files[0].name + "---\n" + fileB64;
        }

        const encrypted = await encryptPayload(plaintext);

        status.textContent = "Computing proof of work (a few seconds)...";
        const powStamp = await computeProofOfWork(NYMDROP_POW_DIFFICULTY);

        status.textContent = "Sending...";
        const resp = await fetch("/submit", {
            method: "POST",
            headers: {
                "Content-Type": "application/octet-stream",
                "X-Nymdrop-Pow": powStamp,
            },
            body: encrypted,
        });

        if (resp.ok) {
            document.getElementById("drop-form").reset();
            document.getElementById("filename").textContent = "";
            status.textContent = "Delivered. No record of this submission exists on the server.";
        } else {
            status.className = "error";
            status.textContent = "Delivery failed. Try again.";
        }
    } catch (err) {
        status.className = "error";
        status.textContent = "Encryption failed: " + err.message;
    }
});