///|
/// AEAD associated data for a protected secret key packet
/// (RFC 9580 §5.5.3): the packet tag byte in OpenPGP encoding followed by
/// the Public Key packet fields, starting with the version.
fn secret_key_associated_data(
key : @packet.SecretKeyPacket,
tag : Int32,
) -> Bytes {
@primitives.concat2(Bytes::from_array([tag.to_byte()]), key.public.to_body())
}
///|
/// HKDF `info` for a usage-253 secret key packet (RFC 9580 §5.5.3): packet
/// tag byte, packet version, cipher algorithm, and AEAD mode.
fn secret_key_hkdf_info(key : @packet.SecretKeyPacket, tag : Int32) -> Bytes {
Bytes::from_array([
tag.to_byte(),
key.public.version().to_byte(),
key.cipher_algo.to_byte(),
key.aead_algo.to_byte(),
])
}
///|
/// The AEAD mode of a usage-253 secret key packet.
fn secret_key_aead_mode(
key : @packet.SecretKeyPacket,
) -> @cipher.AeadMode raise {
match @cipher.aead_mode_of_number(key.aead_algo) {
Some(m) => m
None => @error.fail("api: unknown AEAD algorithm \{key.aead_algo}")
}
}
///|
/// Decrypt (unlock) the secret material of a v4/v6 key packet in place.
/// Supports S2K usage 253 (AEAD + HKDF key separation, including Argon2
/// S2K), 254 (SHA-1 check) and 255 / legacy cipher-id (2-octet checksum)
/// with AES ciphers. The result carries usage 0 plaintext material.
///
/// `tag` is the packet tag byte (0xC5 Secret Key, 0xC7 Secret Subkey),
/// which RFC 9580 feeds into both HKDF `info` and the AEAD associated data.
fn unlock_secret_key(
key : @packet.SecretKeyPacket,
passphrase : String,
tag : Int32,
argon2_max_memory_kib : Int32,
) -> @packet.SecretKeyPacket raise {
guard key.s2k_usage != 0 else { key }
guard !key.dummy else {
@error.fail("api: secret material is a GnuPG dummy (moved to a smartcard)")
}
// The read side accepts the legacy protection ciphers too (RFC 4880
// §9.2 MUST 3DES; CAST5 was GnuPG's pre-AES default) — generation
// still only ever writes AES (see `lock_secret_key`).
guard @primitives.cipher_algo_of_number(key.cipher_algo) is Some(_) else {
@error.fail("api: unsupported protection cipher \{key.cipher_algo}")
}
let algo = @error.require(
@primitives.cipher_algo_of_number(key.cipher_algo),
"api: unsupported protection cipher",
)
let s2k = (@s2k.parse(key.s2k_spec.exact_view()) catch {
_ => @error.fail("api: bad S2K in locked key")
}).0
let pass = @primitives.utf8_bytes(passphrase)
if key.s2k_usage == 253 {
// RFC 9580 §5.5.3: the S2K key is stretched with HKDF-SHA256 (empty
// salt, info = tag|version|cipher|aead) before it protects the
// plaintext with the packet's AEAD mode; the tag replaces any checksum.
let mode = secret_key_aead_mode(key)
let s2k_key = s2k.derive_key_capped(
pass.exact_view(),
algo.key_size(),
argon2_max_memory_kib,
)
let dek = @primitives.hkdf_sha256(
s2k_key,
Bytes::new(0),
secret_key_hkdf_info(key, tag),
algo.key_size(),
)
let bc = @primitives.new_block_cipher(algo, dek.exact_view())
let ad = secret_key_associated_data(key, tag)
let material = try {
match mode {
@cipher.Ocb =>
@cipher.ocb_decrypt(
bc,
key.iv.exact_view(),
ad.exact_view(),
key.secret_material.exact_view(),
)
@cipher.Gcm =>
@cipher.gcm_decrypt(
bc,
key.iv.exact_view(),
ad.exact_view(),
key.secret_material.exact_view(),
)
@cipher.Eax =>
@cipher.eax_decrypt(
bc,
key.iv.exact_view(),
ad.exact_view(),
key.secret_material.exact_view(),
)
}
} catch {
_ =>
@error.fail(
"api: secret key decryption failed (wrong passphrase or corrupted key)",
)
}
return @packet.SecretKeyPacket::{
public: key.public,
s2k_usage: 0,
cipher_algo: 0,
aead_algo: 0,
s2k_spec: Bytes::new(0),
iv: Bytes::new(0),
secret_material: material,
dummy: false,
}
}
guard !s2k.is_argon2() else {
// Parse already rejects this, but a caller-built packet could still
// reach here; RFC 9580 §3.7.2.1 says Argon2 is AEAD-only.
@error.fail("api: Argon2 S2K requires AEAD (usage 253) for a secret key")
}
let kek = s2k.derive_key_capped(
pass.exact_view(),
algo.key_size(),
argon2_max_memory_kib,
)
let bc = @primitives.new_block_cipher(algo, kek.exact_view())
let plain = @cipher.cfb_decrypt(
bc,
key.iv.exact_view(),
key.secret_material.exact_view(),
) catch {
_ => @error.fail("api: secret key decryption failed")
}
// Split off the trailing check: 20-byte SHA-1 (usage 254) or 2-byte sum.
let check_len : Int32 = if key.s2k_usage == 254 { 20 } else { 2 }
guard plain.length() > check_len else {
@error.fail("api: decrypted secret material too short")
}
let material = plain[0:plain.length() - check_len]
let check = plain[plain.length() - check_len:plain.length()]
if key.s2k_usage == 254 {
let want = @primitives.Sha1.digest(material.exact_view())
guard @primitives.ct_equal(want.exact_view(), check.exact_view()) else {
@error.fail("api: wrong passphrase (SHA-1 check failed)")
}
} else {
let want = @packet.session_key_checksum(material.exact_view())
let got = (check[0].to_int() << 8) | check[1].to_int()
guard want == got else {
@error.fail("api: wrong passphrase (checksum failed)")
}
}
@packet.SecretKeyPacket::{
public: key.public,
s2k_usage: 0,
cipher_algo: 0,
aead_algo: 0,
s2k_spec: Bytes::new(0),
iv: Bytes::new(0),
secret_material: material.to_owned(),
dummy: false,
}
}
///|
/// Protect the secret material with a passphrase: AES-256 CFB with an
/// iterated+salted S2K over SHA-256 and a SHA-1 check (usage 254). This is
/// the v4-compatible shape GnuPG 2.4 reads; use `PrivateKey::lock_argon2`
/// for the RFC 9580 recommendation.
fn lock_secret_key(
key : @packet.SecretKeyPacket,
passphrase : String,
) -> @packet.SecretKeyPacket raise {
guard key.s2k_usage == 0 else { @error.fail("api: key is already locked") }
let salt = @primitives.random_bytes(@s2k.SALT_SIZE)
let s2k : @s2k.S2k = @s2k.Iterated(
@primitives.Sha256,
salt,
@s2k.encode_count(S2K_COUNT_TARGET),
)
let kek = s2k.derive_key(@primitives.utf8_bytes(passphrase).exact_view(), 32)
let bc = @primitives.new_block_cipher(@primitives.Aes256, kek.exact_view())
let iv = @primitives.random_bytes(16)
let material = key.secret_material
let check = @primitives.Sha1.digest(material.exact_view())
let plain = material.add(check)
let ciphertext = @cipher.cfb_encrypt(bc, iv.exact_view(), plain.exact_view())
let spec_w = @packet.Writer::new()
spec_w.bytes(s2k.encode().exact_view())
@packet.SecretKeyPacket::{
public: key.public,
s2k_usage: 254,
cipher_algo: @primitives.Aes256.number(),
aead_algo: 0,
s2k_spec: spec_w.to_bytes(),
iv,
secret_material: ciphertext,
dummy: false,
}
}
///|
/// Protect the secret material with Argon2id + AEAD (S2K usage octet 253,
/// RFC 9580 §3.7.1.4/§5.5.3): AES-256 in OCB mode, the Argon2 tag stretched
/// through HKDF-SHA256 for key separation, and the public key fields as AEAD
/// associated data. Defaults (t=3, p=4, m=2^16 KiB) match go-crypto and the
/// gopenpgp RFC 9580 profile.
fn lock_secret_key_argon2(
key : @packet.SecretKeyPacket,
passphrase : String,
tag : Int32,
passes : Int32,
parallelism : Int32,
memory_exp : Int32,
) -> @packet.SecretKeyPacket raise {
guard key.s2k_usage == 0 else { @error.fail("api: key is already locked") }
let salt = @primitives.random_bytes(@s2k.ARGON2_SALT_SIZE)
let s2k : @s2k.S2k = @s2k.Argon2(salt, passes, parallelism, memory_exp)
let spec = s2k.encode() // validates the parameter domain
let algo = @primitives.Aes256
let cipher_algo = algo.number()
let aead_algo = @cipher.Ocb.number()
let nonce_len = @cipher.Ocb.iv_length()
if key.public.version() >= 6 {
// The v6 parameter count is a single octet (RFC 9580 §5.5.3).
guard 3 + spec.length() + nonce_len <= 0xFF else {
@error.fail("api: v6 AEAD secret key parameters exceed one octet")
}
}
let kek = s2k.derive_key(
@primitives.utf8_bytes(passphrase).exact_view(),
algo.key_size(),
)
let info = Bytes::from_array([
tag.to_byte(),
key.public.version().to_byte(),
cipher_algo.to_byte(),
aead_algo.to_byte(),
])
let dek = @primitives.hkdf_sha256(kek, Bytes::new(0), info, algo.key_size())
let bc = @primitives.new_block_cipher(algo, dek.exact_view())
let ad = secret_key_associated_data(key, tag)
let nonce = @primitives.random_bytes(nonce_len)
let ciphertext = @cipher.ocb_encrypt(
bc,
nonce.exact_view(),
ad.exact_view(),
key.secret_material.exact_view(),
)
@packet.SecretKeyPacket::{
public: key.public,
s2k_usage: 253,
cipher_algo,
aead_algo,
s2k_spec: spec,
iv: nonce,
secret_material: ciphertext,
dummy: false,
}
}
///|
/// Unlock the primary key (and its subkey) of a transferable private key
/// with a passphrase. Returns a copy with plaintext secret material.
/// Handles both the CFB (254/255) and the AEAD + Argon2 (253) protections.
pub fn PrivateKey::unlock(
self : PrivateKey,
passphrase : String,
argon2_max_memory_kib~ : Int32 = @primitives.DEFAULT_ARGON2_MAX_MEMORY_KIB,
) -> PrivateKey raise {
let subkeys : Array[SecretSubkeyCert] = []
for c in self.subkeys {
subkeys.push(SecretSubkeyCert::{
secret: unlock_secret_key(
c.secret,
passphrase,
0xC7,
argon2_max_memory_kib,
),
binding: c.binding,
revocation: c.revocation,
})
}
{
primary: unlock_secret_key(
self.primary,
passphrase,
0xC5,
argon2_max_memory_kib,
),
user_ids: self.user_ids,
direct_signature: self.direct_signature,
subkeys,
revocations: self.revocations,
}
}
///|
/// Protect every secret component with a passphrase (usage 254, CFB +
/// SHA-1), the shape GnuPG 2.4 interoperates with.
pub fn PrivateKey::lock(
self : PrivateKey,
passphrase : String,
) -> PrivateKey raise {
let subkeys : Array[SecretSubkeyCert] = []
for c in self.subkeys {
subkeys.push(SecretSubkeyCert::{
secret: lock_secret_key(c.secret, passphrase),
binding: c.binding,
revocation: c.revocation,
})
}
{
primary: lock_secret_key(self.primary, passphrase),
user_ids: self.user_ids,
direct_signature: self.direct_signature,
subkeys,
revocations: self.revocations,
}
}
///|
/// Protect every secret component with Argon2id + AES-256/OCB (S2K usage
/// octet 253) — the RFC 9580 recommendation, and what the gopenpgp
/// RFC 9580 profile produces. `memory_exp` is the exponent of the memory
/// size in KiB (default 16 → 64 MiB); `passes` and `parallelism` default to
/// go-crypto's t=3, p=4.
pub fn PrivateKey::lock_argon2(
self : PrivateKey,
passphrase : String,
passes~ : Int32 = 3,
parallelism~ : Int32 = 4,
memory_exp~ : Int32 = 16,
) -> PrivateKey raise {
let subkeys : Array[SecretSubkeyCert] = []
for c in self.subkeys {
subkeys.push(SecretSubkeyCert::{
secret: lock_secret_key_argon2(
c.secret,
passphrase,
0xC7,
passes,
parallelism,
memory_exp,
),
binding: c.binding,
revocation: c.revocation,
})
}
{
primary: lock_secret_key_argon2(
self.primary,
passphrase,
0xC5,
passes,
parallelism,
memory_exp,
),
user_ids: self.user_ids,
direct_signature: self.direct_signature,
subkeys,
revocations: self.revocations,
}
}
///|
/// True when any secret component (primary key or a subkey) is still
/// passphrase-protected. `unlock` unlocks all components together and
/// gopenpgp's `IsLocked` uses the same "any component" semantics, so
/// looking at the primary alone misreports a key whose subkey is the
/// protected one.
pub fn PrivateKey::is_locked(self : PrivateKey) -> Bool {
if self.primary.s2k_usage != 0 {
return true
}
for c in self.subkeys {
if c.secret.s2k_usage != 0 {
return true
}
}
false
}
///|
/// True when any secret component uses the RFC 9580 AEAD protection
/// (S2K usage octet 253).
pub fn PrivateKey::is_aead_protected(self : PrivateKey) -> Bool {
if self.primary.s2k_usage == 253 {
return true
}
for c in self.subkeys {
if c.secret.s2k_usage == 253 {
return true
}
}
false
}