Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,16 +387,18 @@ The relay is **untrusted by design** — it never holds a decryption key.
| What a compromised relay can do | What it cannot do |
|---------------------------------|-------------------|
| Deny service | Read file contents |
| Learn transfer timing | Substitute a registered public key |
| See blob sizes (fixed 5 MB) | Decrypt any stored ciphertext |
| Learn transfer timing | Decrypt any stored ciphertext |
| See blob sizes (fixed 5 MB) | Substitute a registered public key once a fingerprint has been verified out-of-band (TOFU on first contact — verify the recipient's fingerprint via a separate channel before sending) |

**Crypto stack:**

| Layer | Standard |
|-------|----------|
| Key encapsulation | ML-KEM-768 · NIST FIPS 203 |
| Hybrid KEM (browser path) | ML-KEM-768 + ECDH P-256, combined via HKDF-SHA256 |
| Symmetric | AES-256-GCM · NIST SP 800-38D |
| Signatures | ML-DSA-65 · NIST FIPS 204 |
| Signatures (relay STH / receipts) | ML-DSA-65 · NIST FIPS 204 |
| Signatures (client, SDK only) | ML-DSA-65 over `ctKem ‖ senderPub ‖ nonce ‖ ct ‖ aad` (Node/Python SDK; browser ParaShare path does not yet sign client-side) |
| Key derivation | HKDF-SHA256 · RFC 5869 |
| Password blobs | Argon2id · RFC 9106 |
| Crypto runtime | Rust/WASM — browser-side encryption runs in native code |
Expand Down
9 changes: 9 additions & 0 deletions crypto-wasm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,12 @@ zeroize = "1"
[profile.release]
opt-level = "s"
lto = true

# Disable wasm-pack's default wasm-opt step. wasm-opt requires downloading the
# binaryen release tarball at build time, which fails in sandboxed/offline
# environments and breaks reproducibility of the SHA-256 pin in
# frontend/crypto-bridge.js. Code-size cost is ~50 KB on top of the LTO'd
# release binary; that is an acceptable trade for "builds match the pin
# anywhere with just stable Rust + wasm-pack".
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
77 changes: 55 additions & 22 deletions crypto-wasm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@ use p256::{
PublicKey as P256PublicKey, SecretKey as P256SecretKey,
elliptic_curve::sec1::ToEncodedPoint,
};
use aes_gcm::{Aes256Gcm, Key as AesKey, Nonce, aead::{Aead, KeyInit as AesKeyInit}};
use aes_gcm::{Aes256Gcm, Key as AesKey, Nonce, aead::{Aead, KeyInit as AesKeyInit, Payload}};
use hkdf::Hkdf;
use sha2::Sha256;
use rand_core::OsRng;

const INFO: &[u8] = b"paramant-v2";
const BLOCK: usize = 5 * 1024 * 1024;

// Wire-format magic byte. 0x02 = legacy v0 (no AAD). 0x03 = current (AAD-bound).
// decrypt_blob accepts both. encrypt_blob always produces 0x03.
const MAGIC_LEGACY: u8 = 0x02;
const MAGIC_AAD: u8 = 0x03;

fn hkdf_aes_key(ss_kem: &[u8], ss_ecdh: &[u8], salt: &[u8]) -> Result<[u8; 32], JsValue> {
let mut ikm = Vec::with_capacity(ss_kem.len() + ss_ecdh.len());
ikm.extend_from_slice(ss_kem);
Expand All @@ -33,8 +38,10 @@ fn hkdf_aes_key(ss_kem: &[u8], ss_ecdh: &[u8], salt: &[u8]) -> Result<[u8; 32],

/// Encrypt plaintext for a receiver identified by kem_pub (1184 B) and ecdh_pub (65 B).
/// Returns a 5 MB padded blob in wire format:
/// 0x02 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct
/// Matches the JS hybrid-KEM construction in parashare.html exactly.
/// 0x03 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct
/// All bytes from offset 0 through and including u32be(ctLen) are passed to AES-256-GCM
/// as Associated Data so any tampering with wire metadata fails verification explicitly,
/// rather than relying on cascade failure via HKDF salt = ctKem[..32].
#[wasm_bindgen]
pub fn encrypt_blob(plaintext: &[u8], kem_pub: &[u8], ecdh_pub: &[u8]) -> Result<Vec<u8>, JsValue> {
// ML-KEM-768 encapsulate
Expand All @@ -60,25 +67,36 @@ pub fn encrypt_blob(plaintext: &[u8], kem_pub: &[u8], ecdh_pub: &[u8]) -> Result
// HKDF-SHA256 → AES-256-GCM key (salt = ctKem[..32], matches JS)
let aes_key = hkdf_aes_key(ss_kem_bytes, ss_ecdh_bytes.as_ref(), &ct_kem_bytes[..32])?;

// AES-256-GCM encrypt
// Build the wire-format prelude (everything from magic byte through u32be(ctLen)).
// This is the AES-GCM Associated Data: it commits the AEAD tag to every structural
// field of the blob, so any in-flight mutation fails with a clean auth error.
let ct_len_u32 = u32::try_from(plaintext.len() + 16) // +16 for AES-GCM tag
.map_err(|_| JsValue::from_str("plaintext too large for u32 length prefix"))?;
let mut aad = Vec::with_capacity(
1 + 4 + ct_kem_bytes.len() + 4 + sender_pub_raw.len() + 12 + 4,
);
aad.push(MAGIC_AAD);
aad.extend_from_slice(&(ct_kem_bytes.len() as u32).to_be_bytes());
aad.extend_from_slice(ct_kem_bytes);
aad.extend_from_slice(&(sender_pub_raw.len() as u32).to_be_bytes());
aad.extend_from_slice(sender_pub_raw);
let mut nonce_buf = [0u8; 12];
getrandom::getrandom(&mut nonce_buf).map_err(|e| JsValue::from_str(&e.to_string()))?;
aad.extend_from_slice(&nonce_buf);
aad.extend_from_slice(&ct_len_u32.to_be_bytes());

// AES-256-GCM encrypt with AAD
let cipher = Aes256Gcm::new(AesKey::<Aes256Gcm>::from_slice(&aes_key));
let ct = cipher
.encrypt(Nonce::from_slice(&nonce_buf), plaintext)
.encrypt(Nonce::from_slice(&nonce_buf), Payload { msg: plaintext, aad: &aad })
.map_err(|_| JsValue::from_str("AES-GCM encrypt failed"))?;
if ct.len() as u32 != ct_len_u32 {
return Err(JsValue::from_str("AES-GCM produced unexpected ciphertext length"));
}

// Wire packet
let mut pkt = Vec::with_capacity(
1 + 4 + ct_kem_bytes.len() + 4 + sender_pub_raw.len() + 12 + 4 + ct.len(),
);
pkt.push(0x02);
pkt.extend_from_slice(&(ct_kem_bytes.len() as u32).to_be_bytes());
pkt.extend_from_slice(ct_kem_bytes);
pkt.extend_from_slice(&(sender_pub_raw.len() as u32).to_be_bytes());
pkt.extend_from_slice(sender_pub_raw);
pkt.extend_from_slice(&nonce_buf);
pkt.extend_from_slice(&(ct.len() as u32).to_be_bytes());
// Wire packet — prelude (== AAD) followed by the AEAD ciphertext+tag
let mut pkt = Vec::with_capacity(aad.len() + ct.len());
pkt.extend_from_slice(&aad);
pkt.extend_from_slice(&ct);

// Pad to 5 MB with random bytes
Expand All @@ -98,9 +116,15 @@ pub fn encrypt_blob(plaintext: &[u8], kem_pub: &[u8], ecdh_pub: &[u8]) -> Result
/// ecdh_priv : 32-byte P-256 scalar (big-endian, raw private key bytes)
#[wasm_bindgen]
pub fn decrypt_blob(ciphertext: &[u8], kem_priv: &[u8], ecdh_priv: &[u8]) -> Result<Vec<u8>, JsValue> {
if ciphertext.is_empty() || ciphertext[0] != 0x02 {
return Err(JsValue::from_str("unexpected packet magic (expected 0x02)"));
if ciphertext.is_empty() {
return Err(JsValue::from_str("empty ciphertext"));
}
let magic = ciphertext[0];
let aad_bound = match magic {
MAGIC_AAD => true,
MAGIC_LEGACY => false,
_ => return Err(JsValue::from_str("unexpected packet magic (expected 0x02 or 0x03)")),
};
let mut off = 1usize;

macro_rules! rd_u32 {
Expand All @@ -121,6 +145,7 @@ pub fn decrypt_blob(ciphertext: &[u8], kem_priv: &[u8], ecdh_priv: &[u8]) -> Res
let ct_kem_len = rd_u32!(); let ct_kem = rd_slice!(ct_kem_len);
let sender_len = rd_u32!(); let sender_raw = rd_slice!(sender_len);
let nonce = rd_slice!(12);
let ct_off_before_len = off;
let ct_len = rd_u32!(); let ct = rd_slice!(ct_len);

// ML-KEM-768 decapsulate — load noble's 2400-byte NIST expanded dk
Expand All @@ -142,9 +167,17 @@ pub fn decrypt_blob(ciphertext: &[u8], kem_priv: &[u8], ecdh_priv: &[u8]) -> Res
// HKDF-SHA256 → AES-256-GCM key
let aes_key = hkdf_aes_key(ss_kem_arr.as_slice(), ss_ecdh.raw_secret_bytes().as_ref(), &ct_kem[..32])?;

// AES-256-GCM decrypt
// AES-256-GCM decrypt. For 0x03 blobs the AAD covers the entire wire prelude
// (magic .. ctLen); for legacy 0x02 blobs no AAD is used.
let cipher = Aes256Gcm::new(AesKey::<Aes256Gcm>::from_slice(&aes_key));
cipher
.decrypt(Nonce::from_slice(nonce), ct)
.map_err(|_| JsValue::from_str("AES-GCM decrypt failed — wrong key or corrupted data"))
if aad_bound {
let aad = &ciphertext[..ct_off_before_len + 4];
cipher
.decrypt(Nonce::from_slice(nonce), Payload { msg: ct, aad })
.map_err(|_| JsValue::from_str("AES-GCM decrypt failed — wrong key or corrupted data"))
} else {
cipher
.decrypt(Nonce::from_slice(nonce), ct)
.map_err(|_| JsValue::from_str("AES-GCM decrypt failed — wrong key or corrupted data"))
}
}
41 changes: 26 additions & 15 deletions crypto-wasm/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,32 +13,28 @@
* - Node.js >= 18 (has globalThis.crypto with getRandomValues + subtle)
*/

import { createRequire } from 'module';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { fileURLToPath, pathToFileURL } from 'url';
import path from 'path';

const __dir = path.dirname(fileURLToPath(import.meta.url));

// ── Load noble ML-KEM-768 for keygen ────────────────────────────────────────
// noble-mlkem-bundle.js is a browser bundle — we load it via dynamic eval so
// it can use globalThis.crypto which Node 18+ exposes.
const bundleSrc = readFileSync(path.join(__dir, '../frontend/noble-mlkem-bundle.js'), 'utf8');
const bundleExports = {};
const mod = new Function('exports', bundleSrc + '; Object.assign(exports, { ml_kem768 });');
mod(bundleExports);
const { ml_kem768 } = bundleExports;
// Use the package installed under relay/node_modules so we don't need a
// separate npm install in this directory.
const noblePath = path.join(__dir, '../relay/node_modules/@noble/post-quantum/ml-kem.js');
const { ml_kem768 } = await import(pathToFileURL(noblePath).href);

if (!ml_kem768?.keygen) {
console.error('FAIL: noble-mlkem-bundle.js did not export ml_kem768.keygen');
console.error('FAIL: @noble/post-quantum/ml-kem.js did not export ml_kem768.keygen');
process.exit(1);
}

// ── Load WASM ────────────────────────────────────────────────────────────────
// wasm-pack --target web generates an init() that accepts a WASM buffer.
// We read the binary manually and pass it to init().
const wasmJsPath = path.join(__dir, 'pkg/paramant_crypto.js');
const wasmBinPath = path.join(__dir, 'pkg/paramant_crypto_bg.wasm');
const wasmJsPath = path.join(__dir, '../frontend/pkg/paramant_crypto.js');
const wasmBinPath = path.join(__dir, '../frontend/pkg/paramant_crypto_bg.wasm');

// Patch import.meta.url before importing the generated glue code
// (the glue code uses import.meta.url to locate the WASM file when no arg is given)
Expand Down Expand Up @@ -75,8 +71,8 @@ if (encrypted.length !== 5 * 1024 * 1024) {
console.error('FAIL: unexpected encrypted blob size', encrypted.length);
process.exit(1);
}
if (encrypted[0] !== 0x02) {
console.error('FAIL: unexpected magic byte', encrypted[0].toString(16));
if (encrypted[0] !== 0x03) {
console.error('FAIL: unexpected magic byte (expected 0x03 AAD-bound)', encrypted[0].toString(16));
process.exit(1);
}

Expand All @@ -99,7 +95,22 @@ if (decryptedText !== original) {
console.log('');
console.log('✓ WASM roundtrip OK');
console.log(' plaintext :', original);
console.log(' encrypted : 5 MB padded blob, magic=0x02');
console.log(' encrypted : 5 MB padded blob, magic=0x03 (AAD-bound)');
console.log(' decrypted : matches original');

// ── AAD tampering negative test — flip a byte in the wire prelude ────────────
{
const tampered = encrypted.slice();
tampered[5] ^= 0x01; // flip a bit in u32be(ctKemLen)
let threw = false;
try { decrypt_blob(tampered, kemSec, ecdhPrivRaw); }
catch (_) { threw = true; }
if (!threw) {
console.error('FAIL: tampered prelude was accepted by decrypt_blob');
process.exit(1);
}
console.log('✓ AAD tamper-detection OK — prelude bit-flip rejected');
}

console.log('');
console.log('All checks passed.');
10 changes: 7 additions & 3 deletions frontend/crypto-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
* crypto-bridge.js — wraps the Rust/WASM hybrid KEM (ML-KEM-768 + ECDH P-256 + AES-256-GCM)
* and re-exports the same API that parashare.html, drop.html, and ontvang.html use.
*
* Wire format (produced by WASM, no AAD):
* 0x02 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct
* Wire format produced by WASM (current — magic 0x03, AAD-bound):
* 0x03 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct
* The bytes from offset 0 through u32be(ctLen) are passed to AES-256-GCM as Associated
* Data, so any in-flight mutation of the wire prelude fails authentication explicitly.
* decrypt_blob also still accepts the legacy 0x02 layout (no AAD) for backward compat.
* Padded to 5 MB with random bytes.
*
* Self-integrity: on first init, the WASM binary is fetched and its SHA-256 is checked
Expand All @@ -13,7 +16,8 @@
import init, { encrypt_blob, decrypt_blob } from './pkg/paramant_crypto.js';

// SHA-256 of frontend/pkg/paramant_crypto_bg.wasm — update after each wasm-pack build.
const WASM_SHA256 = 'd009869a8e5eb64e8926a7c8527b15964eac2f075f3707504c596fb65067cc2a';
// Reproducible without binaryen: see [package.metadata.wasm-pack] in crypto-wasm/Cargo.toml.
const WASM_SHA256 = '8e9aca293143d0271cf2134f26c998933c2006099c13da9ae81c86f6940e782b';

let _ready = null;

Expand Down
6 changes: 4 additions & 2 deletions frontend/pkg/paramant_crypto.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@ export function decrypt_blob(ciphertext: Uint8Array, kem_priv: Uint8Array, ecdh_
/**
* Encrypt plaintext for a receiver identified by kem_pub (1184 B) and ecdh_pub (65 B).
* Returns a 5 MB padded blob in wire format:
* 0x02 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct
* Matches the JS hybrid-KEM construction in parashare.html exactly.
* 0x03 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct
* All bytes from offset 0 through and including u32be(ctLen) are passed to AES-256-GCM
* as Associated Data so any tampering with wire metadata fails verification explicitly,
* rather than relying on cascade failure via HKDF salt = ctKem[..32].
*/
export function encrypt_blob(plaintext: Uint8Array, kem_pub: Uint8Array, ecdh_pub: Uint8Array): Uint8Array;

Expand Down
6 changes: 4 additions & 2 deletions frontend/pkg/paramant_crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@ export function decrypt_blob(ciphertext, kem_priv, ecdh_priv) {
/**
* Encrypt plaintext for a receiver identified by kem_pub (1184 B) and ecdh_pub (65 B).
* Returns a 5 MB padded blob in wire format:
* 0x02 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct
* Matches the JS hybrid-KEM construction in parashare.html exactly.
* 0x03 | u32be(ctKemLen) | ctKem | u32be(senderPubLen) | senderPub | nonce(12) | u32be(ctLen) | ct
* All bytes from offset 0 through and including u32be(ctLen) are passed to AES-256-GCM
* as Associated Data so any tampering with wire metadata fails verification explicitly,
* rather than relying on cascade failure via HKDF salt = ctKem[..32].
* @param {Uint8Array} plaintext
* @param {Uint8Array} kem_pub
* @param {Uint8Array} ecdh_pub
Expand Down
Binary file modified frontend/pkg/paramant_crypto_bg.wasm
Binary file not shown.
Loading