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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,41 @@ print(f"Generated cert with fingerprint {mary.fingerprint}:\n{mary}")
Note that legacy PGP implementations may not be able to consume these
certificates yet.

#### Explicit creation times

Pass a timezone-aware datetime as the keyword-only `creation_time` argument
to `Tsk.generate()`:

```python
from datetime import datetime, timedelta, timezone

created = datetime.now(timezone.utc).replace(microsecond=0) - timedelta(days=365)
expired_tsk = Tsk.generate(
"Expired <expired@example.com>",
validity_seconds=335 * 24 * 60 * 60,
creation_time=created,
)
expired_cert = expired_tsk.extract_certificate()
assert expired_cert.expiration == created + timedelta(days=335)
expired_public_key = str(expired_cert)
```

`creation_time` requires a timezone-aware datetime that is between the
lower and upper limit from the OpenPGP standard: between Unix
timestamps 0 and 4294967295 (inclusive); fractional seconds are
discarded. It timestamps the primary key, subkeys and their binding
signatures consistently.

Omitting `creation_time` or passing `None` uses Sequoia's default of 60
seconds before now.

Explicit creation times can produce certificates that are not yet valid or
have already expired. Such certificates can be generated, exported and parsed,
but current-time key selection rejects them for signing, certification and
decryption. For future-dated certificates, accessors such as `user_ids` and
`expiration` may also raise errors because their binding signatures are not
yet valid.

#### Cipher suites

The cryptographic algorithms used for the generated key can be selected
Expand Down
6 changes: 5 additions & 1 deletion python/pysequoia/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -521,12 +521,16 @@ class Tsk:
its associated user IDs, user attributes, subkeys, and signatures).
"""
@staticmethod
def generate(user_id: str | None = None, user_ids: SupportsGetItem[int, str] | None = None, profile: Profile | None = None, cipher_suite: CipherSuite | None = None, validity_seconds: SupportsIndex | None = None, *, signing_algorithm: SigningAlgorithm | None = None, encryption_algorithm: EncryptionAlgorithm | None = None) -> Tsk:
def generate(user_id: str | None = None, user_ids: SupportsGetItem[int, str] | None = None, profile: Profile | None = None, cipher_suite: CipherSuite | None = None, validity_seconds: SupportsIndex | None = None, *, signing_algorithm: SigningAlgorithm | None = None, encryption_algorithm: EncryptionAlgorithm | None = None, creation_time: datetime | None = None) -> Tsk:
"""
Generate a new TSK with a certification-capable primary key,
a signing subkey, and an encryption subkey.

The generated certificate does not expire unless explicitly instructed.
creation_time requires a timezone-aware datetime in OpenPGP's timestamp
range (1970 through 2106). Offsets are normalized to UTC and fractional
seconds discarded. Validity is measured from this time, not from now.
Omitting creation_time uses Sequoia's default (60 seconds before now).
"""
def signer(self, /, password: str | None = None) -> PySigner:
"""
Expand Down
1 change: 1 addition & 0 deletions src/cert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ impl Cert {
validity_seconds,
None,
None,
None,
)?;
tsk.extract_certificate()
}
Expand Down
30 changes: 27 additions & 3 deletions src/cert/secret.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ use std::borrow::Cow;
use std::sync::{Arc, Mutex, MutexGuard};

use pyo3::prelude::*;
use pyo3::types::{PyDateTime, PyTzInfo};
use sequoia_openpgp::parse::Parse as _;
use sequoia_openpgp::types::KeyFlags;
use sequoia_openpgp::types::{KeyFlags, Timestamp};
use sequoia_openpgp::{cert, policy::Policy, serialize::SerializeInto};

use crate::cert::{DEFAULT_POLICY, Profile};
Expand Down Expand Up @@ -98,8 +99,16 @@ impl Tsk {
/// a signing subkey, and an encryption subkey.
///
/// The generated certificate does not expire unless explicitly instructed.
/// creation_time requires a timezone-aware datetime in OpenPGP's timestamp
/// range (1970 through 2106). Offsets are normalized to UTC and fractional
/// seconds discarded. Validity is measured from this time, not from now.
/// Omitting creation_time uses Sequoia's default (60 seconds before now).
#[staticmethod]
#[pyo3(signature = (user_id=None, user_ids=None, profile=None, cipher_suite=None, validity_seconds=None, *, signing_algorithm=None, encryption_algorithm=None))]
#[expect(
clippy::too_many_arguments,
reason = "preserve explicit keyword arguments in the Python API"
)]
#[pyo3(signature = (user_id=None, user_ids=None, profile=None, cipher_suite=None, validity_seconds=None, *, signing_algorithm=None, encryption_algorithm=None, creation_time=None))]
pub fn generate(
user_id: Option<&str>,
user_ids: Option<Vec<String>>,
Expand All @@ -108,6 +117,7 @@ impl Tsk {
validity_seconds: Option<u64>,
signing_algorithm: Option<crate::types::SigningAlgorithm>,
encryption_algorithm: Option<crate::types::EncryptionAlgorithm>,
creation_time: Option<&Bound<'_, PyDateTime>>,
) -> PyResult<Self> {
let mut builder = cert::CertBuilder::new()
.set_profile(profile.unwrap_or_default().into())?
Expand Down Expand Up @@ -138,7 +148,21 @@ impl Tsk {
builder = builder.add_userid(user_id);
}
}

if let Some(creation_time) = creation_time {
if creation_time.call_method0("utcoffset")?.is_none() {
return Err(pyo3::exceptions::PyTypeError::new_err(
"creation_time must be timezone-aware",
));
}
let creation_time = creation_time
.call_method1("astimezone", (PyTzInfo::utc(creation_time.py())?,))?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary to go through Python datetime here, or can we create the appropriate datetime using Chrono?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes unfortunately - Chrono really wants a datetime with tz=utc, so with a datetime object like

import datetime as dt
import zoneinfo

cursed_dt = dt.datetime.now(tz=zoneinfo.ZoneInfo("Europe/Amsterdam"))

chrono can't convert it and raises

ValueError: expected datetime.timezone.utc

(I got that with this line on the Rust side:

let creation_time = creation_time.extract::<chrono::DateTime<chrono::Utc>>()?;

and the cursed_dt as input)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the PR looks good in general but yeah this conversion is ugly... I'll take a look if pyo3 has some better timezone conversion...

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jap

What do you think about including your example:

import datetime as dt
import zoneinfo

cursed_dt = dt.datetime.now(tz=zoneinfo.ZoneInfo("Europe/Amsterdam"))

in the test suite? I see there are some tz tests but they don't look identical... if we have regression tests then I'm happy to merge this now and refactor innards later (but if there are no tests I could break something accidentally)

Unless you think it's already covered then just say so and we're merging this thing 🚀

.extract::<chrono::DateTime<chrono::Utc>>()?;
let creation_time: std::time::SystemTime =
Timestamp::try_from(std::time::SystemTime::from(creation_time))
.map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?
.into();
builder = builder.set_creation_time(creation_time);
}
Ok(builder.generate()?.0.into())
}

Expand Down
74 changes: 73 additions & 1 deletion tests/test_pysequoia.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os
import tempfile
from datetime import datetime
from datetime import datetime, timedelta, timezone

import pytest

Expand Down Expand Up @@ -324,6 +324,78 @@ def store(key_ids):
os.unlink(output_path)


class TestCreationTime:
@pytest.mark.parametrize(
"creation_time, error",
[
pytest.param("2020-01-01", TypeError, id="string"),
pytest.param(datetime(2020, 1, 1), TypeError, id="naive-datetime"),
pytest.param(
datetime(1969, 12, 31, tzinfo=timezone.utc),
ValueError,
id="before-unix-epoch",
),
pytest.param(
datetime(2106, 2, 7, 6, 28, 16, tzinfo=timezone.utc),
ValueError,
id="after-openpgp-timestamp-limit",
),
],
)
def test_invalid_creation_time(self, creation_time, error):
"""Invalid, naive and out-of-range timestamps should raise Python errors."""

with pytest.raises(error):
Tsk.generate(creation_time=creation_time)

def test_creation_time_keyword_only(self):
"""Passing a creation time positionally should fail because it is keyword-only."""
with pytest.raises(TypeError):
Tsk.generate(None, None, None, None, None, datetime.now(timezone.utc))

@pytest.mark.parametrize("profile", [Profile.RFC4880, Profile.RFC9580])
@pytest.mark.parametrize("offset", [0, 5])
def test_consistent_packet_times(self, profile, offset):
"""UTC and offset datetimes should timestamp every key and binding consistently."""

created = datetime(2020, 1, 1, tzinfo=timezone(timedelta(hours=offset)))
tsk = Tsk.generate(
user_ids=["First", "Second"], profile=profile, creation_time=created
)
packets = list(PacketPile.from_bytes(bytes(tsk)))
keys = [p.key_created for p in packets if p.key_created is not None]
signatures = [
p.signature_created for p in packets if p.signature_created is not None
]
assert len(keys) == 3
assert signatures
assert all(t == created for t in keys + signatures)
assert len(tsk.extract_certificate().user_ids) == 2
assert tsk.extract_certificate().expiration is None

def test_expired_public_roundtrip(self):
"""Backdated expired keys should retain expiration, UID and fingerprint in public exports."""

now = datetime.now(timezone.utc).replace(microsecond=0)
created = now - timedelta(days=365)
validity = timedelta(days=335)
tsk = Tsk.generate(
"Expired <expired@example.com>",
validity_seconds=int(validity.total_seconds()),
creation_time=created,
)
cert = tsk.extract_certificate()
assert cert.expiration == created + validity < now
public = str(cert)
assert "PGP PUBLIC KEY BLOCK" in public
packets = list(PacketPile.from_bytes(public.encode()))
assert not any(p.tag in (Tag.SecretKey, Tag.SecretSubkey) for p in packets)
parsed = Cert.from_bytes(public.encode())
assert parsed.fingerprint == cert.fingerprint
assert parsed.expiration == cert.expiration
assert str(parsed.user_ids[0]) == "Expired <expired@example.com>"


class TestTsk:
def test_generate_and_export(self):
tsk = Tsk.generate("Test <test@example.com>")
Expand Down
Loading