///|
/// ECDH (Curve25519Legacy) public-key encryption for session keys,
/// RFC 9580 §5.1.5 and §11.5: one-pass X25519 + SHA-256 KDF + AES key
/// wrap (RFC 3394).
///|
/// Curve25519Legacy secret parts: the wire secret MPI is a big-endian
/// 255-bit clamped scalar; convert to the native little-endian form.
fn ecdh_secret_parts(p : @packet.SecretKeyPacket) -> @pki.X25519SecretKey raise {
guard p.s2k_usage == 0 else {
@error.fail("api: secret key material is locked; unlock it first")
}
guard p.public.algo() == @packet.Ecdh else {
@error.fail("api: not an ECDH key")
}
let r = @packet.Reader::new(p.secret_material.exact_view())
let mpi_value = r.read_mpi()
@pki.x25519_secret_of_wire_mpi(mpi_value.exact_view())
}
///|
/// The ECDH KDF parameter string for a v4 recipient key packet
/// (RFC 9580 §11.5): length-prefixed curve OID || algorithm ID ||
/// KDF-params field (as stored) || "Anonymous Sender " ||
/// 20-octet v4 fingerprint of the encryption subkey.
fn ecdh_kdf_param(pub_packet : @packet.PublicKeyPacket) -> Bytes raise {
let params = pub_packet.params()
guard params.length() == 3 else {
@error.fail("api: bad ECDH public parameters")
}
// Wire order: OID, point, KDF params (RFC 9580 §5.5.5.6).
let oid = params[0]
let kdf_field = params[2]
guard kdf_field.length() == 4 else {
@error.fail("api: bad ECDH KDF parameters")
}
let fp = pub_packet.fingerprint()
guard fp.length() == 20 else {
@error.fail("api: ECDH requires a v4 fingerprint")
}
let w = @packet.Writer::new()
w.bytes(oid.exact_view())
w.u8(18)
w.bytes(kdf_field.exact_view())
w.bytes(@pki.ecdh_anonymous_sender().exact_view())
w.bytes(fp.exact_view())
w.to_bytes()
}
///|
/// The symmetric algorithm id carried in the key's KDF parameters
/// (the key-wrap KEK size selector).
fn ecdh_kdf_sym_algo(pub_packet : @packet.PublicKeyPacket) -> Int32 raise {
// Index only after the guards: the packet layer accepts keys whose KDF
// field is shorter than 4 octets, and callers hand them straight here.
let params = pub_packet.params()
guard params.length() == 3 else {
@error.fail("api: bad ECDH public parameters")
}
let kdf_field = params[2]
guard kdf_field.length() >= 4 else {
@error.fail("api: bad ECDH KDF parameters")
}
kdf_field[3].to_int()
}
///|
/// Derive the KEK from the shared secret and the recipient key packet.
fn ecdh_kek(
shared : BytesView,
pub_packet : @packet.PublicKeyPacket,
) -> Bytes raise {
let sym = ecdh_kdf_sym_algo(pub_packet)
let kek_len = match @primitives.cipher_algo_of_number(sym) {
Some(a) => a.key_size()
None => @error.fail("api: unsupported ECDH KEK cipher \{sym}")
}
@pki.ecdh_kdf_sha256(
shared,
ecdh_kdf_param(pub_packet).exact_view(),
kek_len * 8,
)
}
///|
/// PKCS#5 padding to 8-octet granularity (RFC 9580 §11.5 via RFC 8018).
fn pkcs5_pad8(data : BytesView) -> Bytes {
let n = 8 - data.length() % 8
let out : Array[Byte] = []
for b in data {
out.push(b)
}
for _ in 0.. Bytes raise {
guard data.length() > 0 else { @error.fail("api: empty padded payload") }
let n = data[data.length() - 1].to_int()
guard n >= 1 && n <= 8 && data.length() >= n else {
@error.fail("api: bad session key padding")
}
for i in (data.length() - n).. Bytes raise {
let point = pub_packet.params()[1]
guard point.length() == 33 && point[0] == b'\x40' else {
@error.fail("api: bad Curve25519Legacy point")
}
point[1:point.length()].to_owned()
}
///|
/// ECDH-encrypt the session key to `recipient_pub`: fresh ephemeral
/// scalar, X25519 shared secret, §11.4 KDF, RFC 3394 wrap. Returns the
/// ephemeral point MPI value (0x40 || X) and the wrapped key.
fn ecdh_wrap_session_key(
recipient_pub : @packet.PublicKeyPacket,
session_key : BytesView,
sym_algo : @primitives.CipherAlgo,
) -> (Bytes, Bytes) raise {
let eph = @pki.x25519_generate()
let peer = ecdh_native_point(recipient_pub)
let shared = @pki.x25519_shared_secret(
eph.scalar.exact_view(),
peer.exact_view(),
)
let kek = ecdh_kek(shared.exact_view(), recipient_pub)
let m = @packet.Writer::new()
m.u8(sym_algo.number())
m.bytes(session_key)
m.u16(@packet.session_key_checksum(session_key))
let wrapped = @cipher.aes_key_wrap(
kek.exact_view(),
pkcs5_pad8(m.to_bytes().exact_view()).exact_view(),
)
let point = Bytes::from_array([0x40]).add(eph.public)
(point, wrapped)
}
///|
/// ECDH-decrypt a PKESK with an unlocked Curve25519Legacy secret key.
/// Returns (symmetric algorithm id, session key).
fn ecdh_unwrap_session_key(
pkesk : @packet.PkeskPacket,
sk : @packet.SecretKeyPacket,
) -> (Int32, Bytes) raise {
let secret = ecdh_secret_parts(sk)
let eph = pkesk.ecdh_ephemeral()
guard eph.length() == 33 && eph[0] == b'\x40' else {
@error.fail("api: bad ECDH ephemeral point")
}
let shared = @pki.x25519_shared_secret(
secret.scalar.exact_view(),
eph[1:eph.length()].exact_view(),
)
let kek = ecdh_kek(shared.exact_view(), sk.public)
let m = pkcs5_unpad8(
@cipher.aes_key_unwrap(kek.exact_view(), pkesk.encrypted().exact_view()).exact_view(),
)
guard m.length() >= 3 + 16 else {
@error.fail("api: session key payload too short")
}
let algo = m[0].to_int()
let key = m[1:m.length() - 2].to_owned()
let want = (m[m.length() - 2].to_int() << 8) | m[m.length() - 1].to_int()
guard @packet.session_key_checksum(key.exact_view()) == want else {
@error.fail("api: session key checksum mismatch")
}
(algo, key)
}
///|
/// Build a transferable ECDH (Curve25519Legacy) secret subkey packet.
/// KDF parameters: size 3, reserved 0x01, SHA-256 (id 8) hash,
/// AES-128 (id 7) wrap — the exact form GnuPG writes for cv25519 keys.
fn build_ecdh_secret_key_packet(
creation : Int32,
) -> @packet.SecretKeyPacket raise {
let x = @pki.x25519_generate()
let point = Bytes::from_array([0x40]).add(x.public)
// Params in wire order (RFC 9580 §5.5.5.6): length-prefixed OID,
// point (MPI on the wire), KDF params (length octet + reserved +
// hash + sym).
let params : Array[Bytes] = [
@pki.curve25519_oid(),
point,
Bytes::from_array([0x03, 0x01, 0x08, 0x07]),
]
let pub_packet : @packet.PublicKeyPacket = {
version: 4,
creation_time: creation,
algo: @packet.Ecdh,
params,
}
// Secret material: big-endian 255-bit MPI of the clamped scalar plus
// the 2-octet checksum.
let sw = @packet.Writer::new()
let wire = x.wire_mpi()
sw.u16(255)
sw.bytes(wire[2:wire.length()].exact_view())
let mpi_bytes = sw.to_bytes()
let w2 = @packet.Writer::new()
w2.bytes(mpi_bytes.exact_view())
w2.u16(@packet.session_key_checksum(mpi_bytes.exact_view()))
@packet.SecretKeyPacket::{
public: pub_packet,
s2k_usage: 0,
cipher_algo: 0,
aead_algo: 0,
s2k_spec: Bytes::new(0),
iv: Bytes::new(0),
secret_material: w2.to_bytes(),
dummy: false,
}
}