Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add functionality to sign DSSE envelopes with arbitrary payloads #1054

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 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
30 changes: 30 additions & 0 deletions sigstore/dsse.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright 2022 The Sigstore Authors
# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -210,6 +211,23 @@ def _from_json(cls, contents: bytes | str) -> Envelope:
inner = _Envelope().from_json(contents)
return cls(inner)

@classmethod
def from_payload(cls, payload_type: str, payload: bytes) -> Envelope:
Copy link
Member

Choose a reason for hiding this comment

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

Like elsewhere: we should start with this being a private API, before we make it public.

Suggested change
def from_payload(cls, payload_type: str, payload: bytes) -> Envelope:
def _from_payload(cls, payload_type: str, payload: bytes) -> Envelope:

Copy link
Author

Choose a reason for hiding this comment

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

It is the only "valid" way to create an envelope for users (except of just calling the private method).
Surely, I can change this but just like to get your thoughts on how otherwise a user can sign an envelope.

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, yeah. The problem here is that from_payload is a little misleading as an API: it produces an Envelope, but that Envelope is actually in an invalid state (since it's missing signatures).

Instead of passing an incomplete Envelope in for signing, maybe we could adapt the top-level sign_dsse signature:

    def sign_dsse(
        self,
        input_: dsse.Statement | tuple[str, bytes],
    ) -> Bundle:

...where tuple[str, bytes] is the envelope type and arbitrary contents. That can then be passed into dsse._sign with some similar small tweaks.

(I still don't love that signature, but I think it's a better starting point and avoids the need for a partial constructor here 🙂)

Copy link
Author

Choose a reason for hiding this comment

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

How about using a dataclass?

@dataclass
class DSSEPayload:
    payload_type: str
    payload: bytes

I think at some point the sign_dsse might need to be renamed :) but for now a dataclass would make it clearer for users.

WDYT?

Copy link
Member

Choose a reason for hiding this comment

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

That works for me, although maybe dsse.RawPayload to make it clear that this is the "raw" variant of possible payload types (dsse.Statement being another valid variant).

Copy link
Author

Choose a reason for hiding this comment

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

PTAL

"""Return an unsigned DSSE envelope.

Args:
payload_type (str): The envelope's payload type
payload (bytes): The envelope's payload

Returns:
Envelope: An unsigned DSSE envelope
"""
inner = _Envelope(
payload=payload,
payload_type=payload_type,
)
return cls(inner)

def to_json(self) -> str:
"""
Return a JSON string with this DSSE envelope's contents.
Expand Down Expand Up @@ -256,6 +274,18 @@ def _sign(key: ec.EllipticCurvePrivateKey, stmt: Statement) -> Envelope:
)


def _sign_envelope(
key: ec.EllipticCurvePrivateKey, envelope: Envelope) -> Envelope:
"""
Sign the given envelope's payload and set the signature field
with the generated signature.
"""
pae = _pae(envelope._inner.payload_type, envelope._inner.payload)
signature = key.sign(pae, ec.ECDS(hashes.SHA256()))
susperius marked this conversation as resolved.
Show resolved Hide resolved
envelope._inner.signaures = [Signature(sig=signature)]
return envelope


def _verify(key: ec.EllipticCurvePublicKey, evp: Envelope) -> bytes:
"""
Verify the given in-toto `Envelope`, returning the verified inner payload.
Expand Down
40 changes: 40 additions & 0 deletions sigstore/sign.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright 2022 The Sigstore Authors
# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -229,6 +230,45 @@ def sign_dsse(

return self._finalize_sign(cert, content, proposed_entry)

def sign_dsse_envelope(
Copy link
Member

Choose a reason for hiding this comment

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

I'm a little wary of a new top-level sign API, especially one that's so close to sign_dsse -- instead of making this a new function, what do you think about making sign_dsse take Statement | Envelope?

From there, we'd need to improve the docs. But I think that might be a little cleaner/easier to maintain long-term 🙂

self,
envelope: dsse.Envelope,
) -> Bundle:
"""
Signs the provided envelope's payload, and returns a
`Bundle containing the signed envelope and the verification
material.

Args:
envelope (dsse.Envelope): The envelope to be signed.

Returns:
Bundle: The bundle containing the signed DSSE envelope.
"""
cert = self._signing_cert()

b64_cert = base64.b64encode(
cert.public_bytes(encoding=serialization.Encoding.PEM)
)
envelope = dsse._sign_envelope(
self._private_key,
envelope,
)

proposed_entry = rekor_types.Dsse(
spec=rekor_types.dsse.DsseSchema(
# NOTE: mypy can't see that this kwarg is correct due to two interacting
# behaviors/bugs (one pydantic, one datamodel-codegen):
# See: <https://github.com/pydantic/pydantic/discussions/7418#discussioncomment-9024927>
# See: <https://github.com/koxudaxi/datamodel-code-generator/issues/1903>
proposed_content=rekor_types.dsse.ProposedContent( # type: ignore[call-arg]
envelope=envelope.to_json(),
verifiers=[b64_cert.decode()],
),
),
)
return self._finalize_sign(cert, envelope, proposed_entry)
Copy link
Member

Choose a reason for hiding this comment

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

Making sure I'm not missing anything: does this actually work yet? My understanding was that Rekor doesn't yet support arbitrary DSSE envelopes, since it expects a valid in-toto statement within the envelope.

CC @haydentherapper

Copy link
Contributor

Choose a reason for hiding this comment

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

That is correct, https://github.com/sigstore/rekor/blob/main/pkg/types/dsse/v0.0.1/entry.go#L112-L146, also pointed out in #982 (comment).

The reason is so that Rekor can extract index keys from the statement. We can add other payload types.

Copy link
Member

Choose a reason for hiding this comment

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

Thanks @haydentherapper! I was feeling some deja vu at this 😅

Given the above, I'm not sure we can merge this/it makes sense to merge until Rekor supports other payload types (since users who attempt to use it will hit a hard error and think that sigstore-python is broken).

(Another option here would be to use a DSSE envelope with an arbitrary payload, but to emit a hashedrekord entry instead. I have no strong opinion on whether this is a good idea or not, but it'll likely cause a lot of incompatibilities with the other clients in the ecosystem.)

Copy link
Author

Choose a reason for hiding this comment

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

Oh ok, I missed that. Sorry!
We could log a warning when an arbitrary DSSE envelope is used and skip the Rekor post in finalize sign.
Would that make sense or would it mess up the verification itself?

Copy link
Member

Choose a reason for hiding this comment

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

Would that make sense or would it mess up the verification itself?

That would mess up the verification itself, unfortunately -- that would result in a bundle with no transparency log entries, which would both be invalid and also incompatible with other clients.

Copy link
Contributor

Choose a reason for hiding this comment

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

Even if we made index keys optional for unparseable DSSEs, this would be a breaking change for log monitors that expect an intoto payload in order to extract subject values.

Copy link
Author

Choose a reason for hiding this comment

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

How could we proceed with this then?

  • Would it be possible to use the SHA256 of the payload as index key for arbitrary payloads?
  • Should we define our DSSE envelope payload type before and then add it to rekor? <- this wouldn't be a solution for arbitrary payloads but helps the model signing effort.

Copy link
Author

Choose a reason for hiding this comment

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

@haydentherapper I meant to bring your focus back to this by re-triggering the review ;)


def sign_artifact(
self,
input_: bytes | sigstore_hashes.Hashed,
Expand Down
20 changes: 19 additions & 1 deletion test/unit/test_sign.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Copyright 2022 The Sigstore Authors
# Copyright (c) 2024, NVIDIA CORPORATION. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
Expand All @@ -20,7 +21,7 @@
from sigstore_protobuf_specs.dev.sigstore.common.v1 import HashAlgorithm

import sigstore.oidc
from sigstore.dsse import _StatementBuilder, _Subject
from sigstore.dsse import _StatementBuilder, _Subject, Envelope
from sigstore.errors import VerificationError
from sigstore.hashes import Hashed
from sigstore.sign import SigningContext
Expand Down Expand Up @@ -169,3 +170,20 @@ def test_sign_dsse(staging):
bundle = signer.sign_dsse(stmt)
# Ensures that all of our inner types serialize as expected.
bundle.to_json()


@pytest.mark.staging
@pytest.mark.ambient_oidc
def test_sign_dsse_envelope(staging):
sign_ctx, _, identity = staging

ctx = sign_ctx()
payload = b"Hello World!"
payload_type = "type/custom/my_payload"

env = Envelope.from_payload(payload_type, payload)

with ctx.signer(identity) as signer:
bundle = signer.sign_dsse_envelope(env)

bundle.to_json()