Skip to content
Open
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
289 changes: 235 additions & 54 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
resolver = "2"
members = [
"ace-anchor-kit",
"worker-components/pke",
"worker-components/vss-common",
"worker-components/vss-dealer",
"worker-components/vss-recipient",
Expand Down
45 changes: 43 additions & 2 deletions contracts/pke/sources/pke.move
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
/// PKE abstract layer — scheme-dispatching enums for encryption keys and ciphertexts.
/// Mirrors ts-sdk/src/pke/index.ts.
///
/// Each enum currently has one variant (Simple ElGamal over Ristretto255 = scheme 0).
/// Adding a new scheme is an additive change: add a variant here and a sibling module.
/// Scheme-specific types and serde live in ace::pke_elgamal_otp_ristretto255.
/// Scheme-specific types and serde live in the sibling ace::pke_* modules.
module ace::pke {
use aptos_std::bcs_stream::{Self, BCSStream};
use ace::pke_elgamal_otp_ristretto255;
use ace::pke_hybrid_x25519_mlkem768_chacha20poly1305;
use ace::pke_hpke_x25519_chacha20poly1305;

// ── Error codes ──────────────────────────────────────────────────────────
Expand All @@ -23,19 +23,22 @@ module ace::pke {

const SCHEME_ELGAMAL_OTP_RISTRETTO255: u8 = 0;
const SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305: u8 = 1;
const SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305: u8 = 2;

// ── Outer enum types ─────────────────────────────────────────────────────

/// Wire: [u8 scheme] [inner EncryptionKey bytes]
enum EncryptionKey has copy, drop, store {
ElGamalOtpRistretto255(pke_elgamal_otp_ristretto255::EncryptionKey),
HpkeX25519ChaCha20Poly1305(pke_hpke_x25519_chacha20poly1305::EncryptionKey),
HybridX25519MlKem768ChaCha20Poly1305(pke_hybrid_x25519_mlkem768_chacha20poly1305::EncryptionKey),
}

/// Wire: [u8 scheme] [inner Ciphertext bytes]
enum Ciphertext has copy, drop, store {
ElGamalOtpRistretto255(pke_elgamal_otp_ristretto255::Ciphertext),
HpkeX25519ChaCha20Poly1305(pke_hpke_x25519_chacha20poly1305::Ciphertext),
HybridX25519MlKem768ChaCha20Poly1305(pke_hybrid_x25519_mlkem768_chacha20poly1305::Ciphertext),
}

// ── Public scheme constants ───────────────────────────────────────────────
Expand All @@ -48,6 +51,10 @@ module ace::pke {
SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305
}

public fun scheme_hybrid_x25519_mlkem768_chacha20poly1305(): u8 {
SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305
}

// ── EncryptionKey parse ───────────────────────────────────────────────────

/// Parse an `EncryptionKey` from a BCS stream (reads the leading scheme byte).
Expand All @@ -61,6 +68,10 @@ module ace::pke {
EncryptionKey::HpkeX25519ChaCha20Poly1305(
pke_hpke_x25519_chacha20poly1305::deserialize_enc_key(stream)
)
} else if (scheme == SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) {
EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(
pke_hybrid_x25519_mlkem768_chacha20poly1305::deserialize_enc_key(stream)
)
} else {
abort EUNSUPPORTED_SCHEME
}
Expand All @@ -81,6 +92,7 @@ module ace::pke {
match (ek) {
EncryptionKey::ElGamalOtpRistretto255(_) => SCHEME_ELGAMAL_OTP_RISTRETTO255,
EncryptionKey::HpkeX25519ChaCha20Poly1305(_) => SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305,
EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(_) => SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305,
}
}

Expand All @@ -90,6 +102,7 @@ module ace::pke {
match (ek) {
EncryptionKey::ElGamalOtpRistretto255(inner) => inner,
EncryptionKey::HpkeX25519ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME,
EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME,
}
}

Expand All @@ -99,6 +112,17 @@ module ace::pke {
match (ek) {
EncryptionKey::HpkeX25519ChaCha20Poly1305(inner) => inner,
EncryptionKey::ElGamalOtpRistretto255(_) => abort EUNSUPPORTED_SCHEME,
EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME,
}
}

/// Downcast an `EncryptionKey` to its `HybridX25519MlKem768ChaCha20Poly1305` inner type.
/// Aborts with `EUNSUPPORTED_SCHEME` if the variant does not match.
public fun enc_key_as_hybrid_x25519_mlkem768_chacha20poly1305(ek: EncryptionKey): pke_hybrid_x25519_mlkem768_chacha20poly1305::EncryptionKey {
match (ek) {
EncryptionKey::HybridX25519MlKem768ChaCha20Poly1305(inner) => inner,
EncryptionKey::ElGamalOtpRistretto255(_) => abort EUNSUPPORTED_SCHEME,
EncryptionKey::HpkeX25519ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME,
}
}

Expand All @@ -115,6 +139,10 @@ module ace::pke {
Ciphertext::HpkeX25519ChaCha20Poly1305(
pke_hpke_x25519_chacha20poly1305::deserialize_ciphertext(stream)
)
} else if (scheme == SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305) {
Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(
pke_hybrid_x25519_mlkem768_chacha20poly1305::deserialize_ciphertext(stream)
)
} else {
abort EUNSUPPORTED_SCHEME
}
Expand All @@ -135,6 +163,7 @@ module ace::pke {
match (ct) {
Ciphertext::ElGamalOtpRistretto255(_) => SCHEME_ELGAMAL_OTP_RISTRETTO255,
Ciphertext::HpkeX25519ChaCha20Poly1305(_) => SCHEME_HPKE_X25519_HKDF_SHA256_CHACHA20POLY1305,
Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(_) => SCHEME_HYBRID_X25519_MLKEM768_CHACHA20POLY1305,
}
}

Expand All @@ -144,6 +173,7 @@ module ace::pke {
match (ct) {
Ciphertext::ElGamalOtpRistretto255(inner) => inner,
Ciphertext::HpkeX25519ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME,
Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME,
}
}

Expand All @@ -153,6 +183,17 @@ module ace::pke {
match (ct) {
Ciphertext::HpkeX25519ChaCha20Poly1305(inner) => inner,
Ciphertext::ElGamalOtpRistretto255(_) => abort EUNSUPPORTED_SCHEME,
Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME,
}
}

/// Downcast a `Ciphertext` to its `HybridX25519MlKem768ChaCha20Poly1305` inner type.
/// Aborts with `EUNSUPPORTED_SCHEME` if the variant does not match.
public fun ciphertext_as_hybrid_x25519_mlkem768_chacha20poly1305(ct: Ciphertext): pke_hybrid_x25519_mlkem768_chacha20poly1305::Ciphertext {
match (ct) {
Ciphertext::HybridX25519MlKem768ChaCha20Poly1305(inner) => inner,
Ciphertext::ElGamalOtpRistretto255(_) => abort EUNSUPPORTED_SCHEME,
Ciphertext::HpkeX25519ChaCha20Poly1305(_) => abort EUNSUPPORTED_SCHEME,
}
}

Expand Down
200 changes: 200 additions & 0 deletions contracts/pke/sources/pke_hybrid_x25519_mlkem768_chacha20poly1305.move
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Copyright (c) Aptos Labs
// SPDX-License-Identifier: Apache-2.0

/// Hybrid PKE wire wrappers:
/// inner: HPKE-X25519-HKDF-SHA256-ChaCha20Poly1305
/// outer: ML-KEM-768 shared secret -> HKDF-SHA256 -> ChaCha20-Poly1305
///
/// Move performs no cryptographic operations here. This module only decodes BCS
/// structs and checks lengths so worker config and VSS sessions can carry the
/// bytes on chain without losing structure.
module ace::pke_hybrid_x25519_mlkem768_chacha20poly1305 {
use aptos_std::bcs_stream::{Self, BCSStream};
use ace::pke_hpke_x25519_chacha20poly1305;

// ── Error codes ──────────────────────────────────────────────────────────

const EINVALID_ENC_KEY: u64 = 1;
const EINVALID_CIPHERTEXT: u64 = 2;
const ETRAILING_BYTES: u64 = 3;

// ── Constants ────────────────────────────────────────────────────────────

const MLKEM768_EK_BYTES: u64 = 1184;
const MLKEM768_CT_BYTES: u64 = 1088;
const AEAD_NONCE_BYTES: u64 = 12;
const AEAD_TAG_BYTES: u64 = 16;

// ── Types ────────────────────────────────────────────────────────────────

/// Wire (no outer scheme prefix):
/// HpkeEncryptionKey || [ULEB128(1184)] [1184B ML-KEM ek]
struct EncryptionKey has copy, drop, store {
hpke_x25519: pke_hpke_x25519_chacha20poly1305::EncryptionKey,
mlkem768_ek: vector<u8>,
}

/// Wire (no outer scheme prefix):
/// [ULEB128(1088)] [1088B ML-KEM ct]
/// [ULEB128(12)] [12B nonce]
/// [ULEB128(len)] [len B outer AEAD ct]
struct Ciphertext has copy, drop, store {
mlkem768_ct: vector<u8>,
aead_nonce: vector<u8>,
aead_ct: vector<u8>,
}

// ── Internal helpers ─────────────────────────────────────────────────────

fun deserialize_bytes_field(stream: &mut BCSStream): vector<u8> {
bcs_stream::deserialize_vector(stream, |s| bcs_stream::deserialize_u8(s))
}

// ── EncryptionKey public API ──────────────────────────────────────────────

public fun deserialize_enc_key(stream: &mut BCSStream): EncryptionKey {
let hpke_x25519 = pke_hpke_x25519_chacha20poly1305::deserialize_enc_key(stream);
let mlkem768_ek = deserialize_bytes_field(stream);
assert!(mlkem768_ek.length() == MLKEM768_EK_BYTES, EINVALID_ENC_KEY);
EncryptionKey { hpke_x25519, mlkem768_ek }
}

public fun enc_key_from_bytes(data: vector<u8>): EncryptionKey {
let stream = bcs_stream::new(data);
let ek = deserialize_enc_key(&mut stream);
assert!(!bcs_stream::has_remaining(&mut stream), ETRAILING_BYTES);
ek
}

public fun unpack_enc_key(ek: EncryptionKey): (pke_hpke_x25519_chacha20poly1305::EncryptionKey, vector<u8>) {
let EncryptionKey { hpke_x25519, mlkem768_ek } = ek;
(hpke_x25519, mlkem768_ek)
}

// ── Ciphertext public API ─────────────────────────────────────────────────

public fun deserialize_ciphertext(stream: &mut BCSStream): Ciphertext {
let mlkem768_ct = deserialize_bytes_field(stream);
assert!(mlkem768_ct.length() == MLKEM768_CT_BYTES, EINVALID_CIPHERTEXT);
let aead_nonce = deserialize_bytes_field(stream);
assert!(aead_nonce.length() == AEAD_NONCE_BYTES, EINVALID_CIPHERTEXT);
let aead_ct = deserialize_bytes_field(stream);
assert!(aead_ct.length() >= AEAD_TAG_BYTES, EINVALID_CIPHERTEXT);
Ciphertext { mlkem768_ct, aead_nonce, aead_ct }
}

public fun ciphertext_from_bytes(data: vector<u8>): Ciphertext {
let stream = bcs_stream::new(data);
let ct = deserialize_ciphertext(&mut stream);
assert!(!bcs_stream::has_remaining(&mut stream), ETRAILING_BYTES);
ct
}

public fun unpack_ciphertext(ct: Ciphertext): (vector<u8>, vector<u8>, vector<u8>) {
let Ciphertext { mlkem768_ct, aead_nonce, aead_ct } = ct;
(mlkem768_ct, aead_nonce, aead_ct)
}

// ── Tests ─────────────────────────────────────────────────────────────────

#[test_only]
fun push_bytes(out: &mut vector<u8>, len: u64, seed: u8) {
let i = 0;
while (i < len) {
out.push_back(seed ^ ((i % 251) as u8));
i = i + 1;
}
}

#[test_only]
fun push_uleb_len(out: &mut vector<u8>, len: u64) {
if (len < 128) {
out.push_back(len as u8);
} else if (len == MLKEM768_EK_BYTES) {
out.push_back(0xa0);
out.push_back(0x09);
} else if (len == MLKEM768_CT_BYTES) {
out.push_back(0xc0);
out.push_back(0x08);
} else {
abort 999
}
}

#[test_only]
fun build_enc_key_bytes(mlkem_len: u64): vector<u8> {
let out = vector[];
// HPKE inner key: [ULEB128(32)] [32B pk]
out.push_back(0x20);
push_bytes(&mut out, 32, 0x11);
push_uleb_len(&mut out, mlkem_len);
push_bytes(&mut out, mlkem_len, 0x42);
out
}

#[test_only]
fun build_ciphertext_bytes(mlkem_ct_len: u64, nonce_len: u64, aead_ct_len: u64): vector<u8> {
let out = vector[];
push_uleb_len(&mut out, mlkem_ct_len);
push_bytes(&mut out, mlkem_ct_len, 0x21);
push_uleb_len(&mut out, nonce_len);
push_bytes(&mut out, nonce_len, 0x31);
push_uleb_len(&mut out, aead_ct_len);
push_bytes(&mut out, aead_ct_len, 0x41);
out
}

#[test]
fun test_enc_key_round_trip() {
let bytes = build_enc_key_bytes(MLKEM768_EK_BYTES);
let ek = enc_key_from_bytes(bytes);
let (_, mlkem768_ek) = unpack_enc_key(ek);
assert!(mlkem768_ek.length() == MLKEM768_EK_BYTES, 100);
}

#[test]
#[expected_failure(abort_code = EINVALID_ENC_KEY)]
fun test_enc_key_wrong_mlkem_length_rejected() {
let bytes = build_enc_key_bytes(32);
enc_key_from_bytes(bytes);
}

#[test]
#[expected_failure(abort_code = ETRAILING_BYTES)]
fun test_enc_key_trailing_bytes_rejected() {
let bytes = build_enc_key_bytes(MLKEM768_EK_BYTES);
bytes.push_back(0xff);
enc_key_from_bytes(bytes);
}

#[test]
fun test_ciphertext_round_trip() {
let bytes = build_ciphertext_bytes(MLKEM768_CT_BYTES, AEAD_NONCE_BYTES, AEAD_TAG_BYTES + 8);
let ct = ciphertext_from_bytes(bytes);
let (mlkem768_ct, nonce, aead_ct) = unpack_ciphertext(ct);
assert!(mlkem768_ct.length() == MLKEM768_CT_BYTES, 200);
assert!(nonce.length() == AEAD_NONCE_BYTES, 201);
assert!(aead_ct.length() == AEAD_TAG_BYTES + 8, 202);
}

#[test]
#[expected_failure(abort_code = EINVALID_CIPHERTEXT)]
fun test_ciphertext_wrong_mlkem_length_rejected() {
let bytes = build_ciphertext_bytes(32, AEAD_NONCE_BYTES, AEAD_TAG_BYTES);
ciphertext_from_bytes(bytes);
}

#[test]
#[expected_failure(abort_code = EINVALID_CIPHERTEXT)]
fun test_ciphertext_wrong_nonce_length_rejected() {
let bytes = build_ciphertext_bytes(MLKEM768_CT_BYTES, 11, AEAD_TAG_BYTES);
ciphertext_from_bytes(bytes);
}

#[test]
#[expected_failure(abort_code = EINVALID_CIPHERTEXT)]
fun test_ciphertext_short_aead_rejected() {
let bytes = build_ciphertext_bytes(MLKEM768_CT_BYTES, AEAD_NONCE_BYTES, AEAD_TAG_BYTES - 1);
ciphertext_from_bytes(bytes);
}
}
2 changes: 1 addition & 1 deletion docs/auditor/cryptography/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Defined in `contracts/group/sources/group.move` and mirrored in `worker-componen

The following were called out in earlier discussions and are **not** in the current codebase. Auditors should not flag their absence; they're tracked as future work.

- **Post-quantum PKE.** No PQ-hybrid or PQ-only scheme is currently shipped. (Future: HPKE-X-Wing or Kyber-hybrid; tracked separately.)
- **Production post-quantum PKE.** Scheme `0x02` is a TS/Rust PQ-hybrid prototype with Move-side decoding, but no audited production PQ-hybrid or PQ-only PKE is currently shipped. (Future: HPKE-X-Wing or another standardized hybrid; tracked separately.)
- **256-bit security level PKE.** Both PKE schemes are ~128-bit. (Future: HPKE-X448-HKDF-SHA512-ChaCha20Poly1305 or similar.)
- **t-IBE share proof.** The `IdentityDecryptionKeyShare` wire format reserves a 1-byte "proof" flag for a future per-share Schnorr proof; today it is always `0x00` (no proof). The verification check in [`t-ibe.md`](./t-ibe.md) §1 uses on-chain `share_pks` instead, which is sufficient for honest-majority assumptions but not for accountability under accusatory failure.
- **Move-side HPKE / shortsig-aead encrypt-decrypt.** Move only decodes these formats; the on-chain side never holds a private key for either, so no on-chain encrypt or decrypt is needed.
Expand Down
Loading
Loading