///|
/// Extending an existing certificate (RFC 9580 §10.1 transferable-key
/// surgery): adding subkeys to a key you hold, and setting, extending or
/// clearing expiry by re-issuing the governing self-signatures. All of
/// these re-sign, so the key must be unlocked — the same guard the
/// document-signing path enforces.
///
/// Re-issuance mechanics: the parsed signature's hashed statements are
/// carried over (minus the Key Expiration Time statement, which the new
/// one replaces, and any Signature Expiration, which bound the OLD
/// issuance only), the raw hashed area is dropped so the modified
/// subpacket list is what actually gets hashed (`to_body`/`hash_prefix`
/// prefer raw wire bytes whenever present), and the primary re-signs.
///|
/// The 4-octet Key Expiration Time subpacket body: seconds after the
/// key's creation time (0 = never expires).
fn key_expiration_body(seconds : Int64) -> Bytes raise {
guard seconds >= 0 && seconds <= 0xFFFFFFFFL else {
@error.fail("api: key lifetime must fit the 4-octet subpacket (0..2^32-1)")
}
Bytes::makei(4, i => ((seconds >> (24 - i * 8)) & 0xFFL).to_byte())
}
///|
/// Rebuild a hashed subpacket area with a fresh creation time and the
/// Key Expiration statement inserted right after it (gpg's ordering).
/// Signature Expiration statements are dropped: they bounded the old
/// issuance, and carrying one into a freshly created signature could
/// expire it immediately.
fn hashed_with_key_expiration(
hashed : Array[@packet.SigSubpacket],
seconds : Int64,
now : Int32,
) -> Array[@packet.SigSubpacket] raise {
let sp : @packet.SigSubpacket = {
critical: false,
sub_type: @packet.SIGSUB_KEY_EXPIRATION_TIME,
body: key_expiration_body(seconds),
}
let out : Array[@packet.SigSubpacket] = []
let mut inserted = false
for s in hashed {
if s.sub_type == @packet.SIGSUB_KEY_EXPIRATION_TIME ||
s.sub_type == @packet.SIGSUB_SIG_EXPIRATION_TIME {
continue
}
if s.sub_type == @packet.SIGSUB_CREATION_TIME {
out.push({ ..s, body: creation_subpacket(now).body, })
out.push(sp)
inserted = true
} else {
out.push(s)
}
}
if !inserted {
out.push(sp)
}
out
}
///|
/// Re-issue a parsed signature over a modified hashed area and re-sign
/// it with `secret`; `digest_of` supplies the hash input for the
/// signature type (certification / direct key / binding). The raw hashed
/// area is dropped so the modified subpacket list is what gets hashed
/// (`to_body`/`hash_prefix` prefer raw wire bytes whenever present).
fn reissue_sig(
sig : @packet.SignaturePacket,
secret : @packet.SecretKeyPacket,
hashed : Array[@packet.SigSubpacket],
digest_of : (@packet.SignaturePacket) -> Bytes raise,
) -> @packet.SignaturePacket raise {
let fresh : @packet.SignaturePacket = {
..sig,
hashed_subpackets: hashed,
raw_hashed_area: Bytes::new(0),
fast_hash: 0,
sig_params: [],
}
let digest = digest_of(fresh)
let hash_id = match @primitives.hash_id_of_number(fresh.hash_algo()) {
Some(h) => h
None =>
@error.unsupported(
"api: signature uses unknown hash algorithm \{fresh.hash_algo()}",
)
}
finish_signature(fresh, secret, hash_id, digest.exact_view())
}
///|
/// Re-issue a parsed signature with new expiry statements and re-sign it
/// with `secret`.
fn resign_sig_with_expiry(
sig : @packet.SignaturePacket,
secret : @packet.SecretKeyPacket,
seconds : Int64,
now : Int32,
digest_of : (@packet.SignaturePacket) -> Bytes raise,
) -> @packet.SignaturePacket raise {
reissue_sig(
sig,
secret,
hashed_with_key_expiration(sig.hashed_subpackets, seconds, now),
digest_of,
)
}
///|
/// Set (or extend, or clear with 0) the primary key's expiry: every
/// governing self-signature — each User ID certification plus the v6
/// Direct Key signature — is re-issued with a Key Expiration Time of
/// `seconds`. The lifetime counts from the KEY's creation time (RFC 9580
/// §5.2.3.6 semantics), so "30 days from key creation" stays 2592000 no
/// matter when the extension happens. The key must be unlocked.
///
/// # Example
///
/// ```mbt check
/// test "an expiry is visible through the lifecycle checks" {
/// let key = @api.generate_ed25519_key("E ").set_expiry(3600)
/// let now = @primitives.unix_now()
/// assert_false(key.is_expired(now))
/// assert_true(key.is_expired(now + 3601))
/// }
/// ```
pub fn PrivateKey::set_expiry(
self : PrivateKey,
seconds : Int64,
created? : Int32,
) -> PrivateKey raise {
guard self.user_ids.length() > 0 || self.direct_signature is Some(_) else {
@error.fail("api: key has no self-signature to carry an expiry")
}
let now = match created {
Some(t) => t
None => @primitives.unix_now().to_int()
}
let user_ids : Array[UserIdBinding] = []
for b in self.user_ids {
let re = resign_sig_with_expiry(
b.self_signature,
self.primary,
seconds,
now,
s => hash_certification(s, self.primary.public, b.user_id),
)
user_ids.push({ ..b, self_signature: re, })
}
let direct = match self.direct_signature {
Some(d) =>
Some(
resign_sig_with_expiry(d, self.primary, seconds, now, s => {
hash_direct_key(s, self.primary.public)
}),
)
None => None
}
{ ..self, user_ids, direct_signature: direct, }
}
///|
/// Set (or extend, or clear with 0) one subkey's expiry by re-issuing
/// its binding signature with a Key Expiration Time of `seconds` —
/// `gpg --quick-set-expire `. The subkey is matched by
/// key id; an embedded cross-certification rides in the unhashed area
/// and survives untouched. The key must be unlocked.
pub fn PrivateKey::set_subkey_expiry(
self : PrivateKey,
subkey_id : BytesView,
seconds : Int64,
created? : Int32,
) -> PrivateKey raise {
let now = match created {
Some(t) => t
None => @primitives.unix_now().to_int()
}
let subkeys : Array[SecretSubkeyCert] = []
let mut found = false
for c in self.subkeys {
let id = c.secret.public.key_id() catch { _ => Bytes::new(0) }
if !found && id.length() > 0 && id.equal(subkey_id.to_owned()) {
let binding = resign_sig_with_expiry(
c.binding,
self.primary,
seconds,
now,
s => hash_binding(s, self.primary.public, c.secret.public),
)
subkeys.push({ ..c, binding, })
found = true
} else {
subkeys.push(c)
}
}
guard found else {
@error.fail("api: no subkey of this key matches the given key id")
}
{ ..self, subkeys, }
}
///|
/// Generate a fresh encryption subkey (Curve25519Legacy ECDH on a v4
/// key, native X25519 on a v6 one) and bind it to this key — the
/// rotation story for an expiring or compromised encryption subkey.
/// The key must be unlocked. Wire order places the new subkey last,
/// which is the one `encryption_key` prefers.
pub fn PrivateKey::add_encryption_subkey(
self : PrivateKey,
created? : Int32,
) -> PrivateKey raise {
let now = match created {
Some(t) => t
None => @primitives.unix_now().to_int()
}
let subkey = if self.primary.public.version() >= 6 {
build_v6_x25519_secret_key_packet(now)
} else {
build_ecdh_secret_key_packet(now)
}
let binding = make_binding_signature(
self.primary,
self.primary.public,
subkey.public,
now,
)
let subkeys = self.subkeys.copy()
subkeys.push(SecretSubkeyCert::{ secret: subkey, binding, revocation: None, })
{ ..self, subkeys, }
}
///|
/// Generate a fresh signing subkey (EdDSA legacy on a v4 key, native
/// Ed25519 on a v6 one) and bind it with the cross-certification gpg
/// requires (RFC 9580 §5.2.3.26) — the rotation story for a signing
/// subkey. The key must be unlocked.
pub fn PrivateKey::add_signing_subkey(
self : PrivateKey,
created? : Int32,
) -> PrivateKey raise {
let now = match created {
Some(t) => t
None => @primitives.unix_now().to_int()
}
let subkey = if self.primary.public.version() >= 6 {
build_ed25519_v6_secret_key_packet(@primitives.random_bytes(32), now)
} else {
build_eddsa_secret_key_packet(@primitives.random_bytes(32), now)
}
let binding = make_binding_signature(
self.primary,
self.primary.public,
subkey.public,
now,
key_flags=0x02,
subkey_secret=subkey,
)
let subkeys = self.subkeys.copy()
subkeys.push(SecretSubkeyCert::{ secret: subkey, binding, revocation: None, })
{ ..self, subkeys, }
}