sigstore.dsse

Functionality for building and manipulating in-toto Statements and DSSE envelopes.

  1# Copyright 2022 The Sigstore Authors
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License");
  4# you may not use this file except in compliance with the License.
  5# You may obtain a copy of the License at
  6#
  7#      http://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS,
 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12# See the License for the specific language governing permissions and
 13# limitations under the License.
 14
 15"""
 16Functionality for building and manipulating in-toto Statements and DSSE envelopes.
 17"""
 18
 19from __future__ import annotations
 20
 21import logging
 22from typing import Any, Dict, List, Literal, Optional, Union
 23
 24from cryptography.exceptions import InvalidSignature
 25from cryptography.hazmat.primitives import hashes
 26from cryptography.hazmat.primitives.asymmetric import ec
 27from pydantic import BaseModel, ConfigDict, Field, RootModel, StrictStr, ValidationError
 28from sigstore_protobuf_specs.dev.sigstore.common.v1 import HashAlgorithm
 29from sigstore_protobuf_specs.io.intoto import Envelope as _Envelope
 30from sigstore_protobuf_specs.io.intoto import Signature
 31
 32from sigstore.errors import Error, VerificationError
 33from sigstore.hashes import Hashed
 34
 35_logger = logging.getLogger(__name__)
 36
 37Digest = Union[
 38    Literal["sha256"],
 39    Literal["sha384"],
 40    Literal["sha512"],
 41    Literal["sha3_256"],
 42    Literal["sha3_384"],
 43    Literal["sha3_512"],
 44]
 45"""
 46NOTE: in-toto's DigestSet contains all kinds of hash algorithms that
 47we intentionally do not support. This model is limited to common members of the
 48SHA-2 and SHA-3 family that are at least as strong as SHA-256.
 49
 50See: <https://github.com/in-toto/attestation/blob/main/spec/v1/digest_set.md>
 51"""
 52
 53DigestSet = RootModel[Dict[Digest, str]]
 54"""
 55An internal validation model for in-toto subject digest sets.
 56"""
 57
 58
 59class Subject(BaseModel):
 60    """
 61    A single in-toto statement subject.
 62    """
 63
 64    name: Optional[StrictStr]
 65    digest: DigestSet = Field(...)
 66
 67
 68class _Statement(BaseModel):
 69    """
 70    An internal validation model for in-toto statements.
 71    """
 72
 73    model_config = ConfigDict(populate_by_name=True)
 74
 75    type_: Literal["https://in-toto.io/Statement/v1"] = Field(..., alias="_type")
 76    subjects: List[Subject] = Field(..., min_length=1, alias="subject")
 77    predicate_type: StrictStr = Field(..., alias="predicateType")
 78    predicate: Optional[Dict[str, Any]] = Field(None, alias="predicate")
 79
 80
 81class Statement:
 82    """
 83    Represents an in-toto statement.
 84
 85    This type deals with opaque bytes to ensure that the encoding does not
 86    change, but Statements are internally checked for conformance against
 87    the JSON object layout defined in the in-toto attestation spec.
 88
 89    See: <https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md>
 90    """
 91
 92    def __init__(self, contents: bytes | _Statement) -> None:
 93        """
 94        Construct a new Statement.
 95
 96        This takes an opaque `bytes` containing the statement; use
 97        `StatementBuilder` to manually construct an in-toto statement
 98        from constituent pieces.
 99        """
100        if isinstance(contents, bytes):
101            self._contents = contents
102            try:
103                self._inner = _Statement.model_validate_json(contents)
104            except ValidationError:
105                raise Error("malformed in-toto statement")
106        else:
107            self._contents = contents.model_dump_json(by_alias=True).encode()
108            self._inner = contents
109
110    def _matches_digest(self, digest: Hashed) -> bool:
111        """
112        Returns a boolean indicating whether this in-toto Statement contains a subject
113        matching the given digest. The subject's name is **not** checked.
114
115        No digests other than SHA256 are currently supported.
116        """
117        if digest.algorithm != HashAlgorithm.SHA2_256:
118            raise VerificationError(f"unexpected digest algorithm: {digest.algorithm}")
119
120        for sub in self._inner.subjects:
121            sub_digest = sub.digest.root.get("sha256")
122            if sub_digest is None:
123                continue
124            if sub_digest == digest.digest.hex():
125                return True
126
127        return False
128
129    def _pae(self) -> bytes:
130        """
131        Construct the PAE encoding for this statement.
132        """
133
134        return _pae(Envelope._TYPE, self._contents)
135
136
137class StatementBuilder:
138    """
139    A builder-style API for constructing in-toto Statements.
140    """
141
142    def __init__(
143        self,
144        subjects: Optional[List[Subject]] = None,
145        predicate_type: Optional[str] = None,
146        predicate: Optional[Dict[str, Any]] = None,
147    ):
148        """
149        Create a new `StatementBuilder`.
150        """
151        self._subjects = subjects or []
152        self._predicate_type = predicate_type
153        self._predicate = predicate
154
155    def subjects(self, subjects: list[Subject]) -> StatementBuilder:
156        """
157        Configure the subjects for this builder.
158        """
159        self._subjects = subjects
160        return self
161
162    def predicate_type(self, predicate_type: str) -> StatementBuilder:
163        """
164        Configure the predicate type for this builder.
165        """
166        self._predicate_type = predicate_type
167        return self
168
169    def predicate(self, predicate: dict[str, Any]) -> StatementBuilder:
170        """
171        Configure the predicate for this builder.
172        """
173        self._predicate = predicate
174        return self
175
176    def build(self) -> Statement:
177        """
178        Build a `Statement` from the builder's state.
179        """
180        try:
181            stmt = _Statement(
182                type_="https://in-toto.io/Statement/v1",
183                subjects=self._subjects,
184                predicate_type=self._predicate_type,
185                predicate=self._predicate,
186            )
187        except ValidationError as e:
188            raise Error(f"invalid statement: {e}")
189
190        return Statement(stmt)
191
192
193class InvalidEnvelope(Error):
194    """
195    Raised when the associated `Envelope` is invalid in some way.
196    """
197
198
199class Envelope:
200    """
201    Represents a DSSE envelope.
202
203    This class cannot be constructed directly; you must use `sign` or `from_json`.
204
205    See: <https://github.com/secure-systems-lab/dsse/blob/v1.0.0/envelope.md>
206    """
207
208    _TYPE = "application/vnd.in-toto+json"
209
210    def __init__(self, inner: _Envelope) -> None:
211        """
212        @private
213        """
214
215        self._inner = inner
216        self._verify()
217
218    def _verify(self) -> None:
219        """
220        Verify and load the Envelope.
221        """
222        if len(self._inner.signatures) != 1:
223            raise InvalidEnvelope("envelope must contain exactly one signature")
224
225        if not self._inner.signatures[0].sig:
226            raise InvalidEnvelope("envelope signature must be non-empty")
227
228        self._signature_bytes = self._inner.signatures[0].sig
229
230    @classmethod
231    def _from_json(cls, contents: bytes | str) -> Envelope:
232        """Return a DSSE envelope from the given JSON representation."""
233        inner = _Envelope().from_json(contents)
234        return cls(inner)
235
236    def to_json(self) -> str:
237        """
238        Return a JSON string with this DSSE envelope's contents.
239        """
240        return self._inner.to_json()
241
242    def __eq__(self, other: object) -> bool:
243        """Equality for DSSE envelopes."""
244
245        if not isinstance(other, Envelope):
246            return NotImplemented
247
248        return self._inner == other._inner
249
250    @property
251    def signature(self) -> bytes:
252        """Return the decoded bytes of the Envelope signature."""
253        return self._signature_bytes
254
255
256def _pae(type_: str, body: bytes) -> bytes:
257    """
258    Compute the PAE encoding for the given `type_` and `body`.
259    """
260
261    # See:
262    # https://github.com/secure-systems-lab/dsse/blob/v1.0.0/envelope.md
263    # https://github.com/in-toto/attestation/blob/v1.0/spec/v1.0/envelope.md
264    pae = f"DSSEv1 {len(type_)} {type_} ".encode()
265    pae += b" ".join([str(len(body)).encode(), body])
266    return pae
267
268
269def _sign(key: ec.EllipticCurvePrivateKey, stmt: Statement) -> Envelope:
270    """
271    Sign for the given in-toto `Statement`, and encapsulate the resulting
272    signature in a DSSE `Envelope`.
273    """
274    pae = stmt._pae()
275    _logger.debug(f"DSSE PAE: {pae!r}")
276
277    signature = key.sign(pae, ec.ECDSA(hashes.SHA256()))
278    return Envelope(
279        _Envelope(
280            payload=stmt._contents,
281            payload_type=Envelope._TYPE,
282            signatures=[Signature(sig=signature)],
283        )
284    )
285
286
287def _verify(key: ec.EllipticCurvePublicKey, evp: Envelope) -> bytes:
288    """
289    Verify the given in-toto `Envelope`, returning the verified inner payload.
290
291    This function does **not** check the envelope's payload type. The caller
292    is responsible for performing this check.
293    """
294
295    pae = _pae(evp._inner.payload_type, evp._inner.payload)
296
297    nsigs = len(evp._inner.signatures)
298    if nsigs != 1:
299        raise VerificationError(f"DSSE: exactly 1 signature allowed, got {nsigs}")
300
301    signature = evp._inner.signatures[0].sig
302
303    try:
304        key.verify(signature, pae, ec.ECDSA(hashes.SHA256()))
305    except InvalidSignature:
306        raise VerificationError("DSSE: invalid signature")
307
308    return evp._inner.payload
Digest = typing.Union[typing.Literal['sha256'], typing.Literal['sha384'], typing.Literal['sha512'], typing.Literal['sha3_256'], typing.Literal['sha3_384'], typing.Literal['sha3_512']]

NOTE: in-toto's DigestSet contains all kinds of hash algorithms that we intentionally do not support. This model is limited to common members of the SHA-2 and SHA-3 family that are at least as strong as SHA-256.

See: https://github.com/in-toto/attestation/blob/main/spec/v1/digest_set.md

DigestSet = <class 'pydantic.root_model.RootModel[Dict[Union[Literal['sha256'], Literal['sha384'], Literal['sha512'], Literal['sha3_256'], Literal['sha3_384'], Literal['sha3_512']], str]]'>

An internal validation model for in-toto subject digest sets.

class Subject(pydantic.main.BaseModel):
60class Subject(BaseModel):
61    """
62    A single in-toto statement subject.
63    """
64
65    name: Optional[StrictStr]
66    digest: DigestSet = Field(...)

A single in-toto statement subject.

name: Optional[Annotated[str, Strict(strict=True)]]
digest: pydantic.root_model.RootModel[Dict[Union[Literal['sha256'], Literal['sha384'], Literal['sha512'], Literal['sha3_256'], Literal['sha3_384'], Literal['sha3_512']], str]]
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class Statement:
 82class Statement:
 83    """
 84    Represents an in-toto statement.
 85
 86    This type deals with opaque bytes to ensure that the encoding does not
 87    change, but Statements are internally checked for conformance against
 88    the JSON object layout defined in the in-toto attestation spec.
 89
 90    See: <https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md>
 91    """
 92
 93    def __init__(self, contents: bytes | _Statement) -> None:
 94        """
 95        Construct a new Statement.
 96
 97        This takes an opaque `bytes` containing the statement; use
 98        `StatementBuilder` to manually construct an in-toto statement
 99        from constituent pieces.
100        """
101        if isinstance(contents, bytes):
102            self._contents = contents
103            try:
104                self._inner = _Statement.model_validate_json(contents)
105            except ValidationError:
106                raise Error("malformed in-toto statement")
107        else:
108            self._contents = contents.model_dump_json(by_alias=True).encode()
109            self._inner = contents
110
111    def _matches_digest(self, digest: Hashed) -> bool:
112        """
113        Returns a boolean indicating whether this in-toto Statement contains a subject
114        matching the given digest. The subject's name is **not** checked.
115
116        No digests other than SHA256 are currently supported.
117        """
118        if digest.algorithm != HashAlgorithm.SHA2_256:
119            raise VerificationError(f"unexpected digest algorithm: {digest.algorithm}")
120
121        for sub in self._inner.subjects:
122            sub_digest = sub.digest.root.get("sha256")
123            if sub_digest is None:
124                continue
125            if sub_digest == digest.digest.hex():
126                return True
127
128        return False
129
130    def _pae(self) -> bytes:
131        """
132        Construct the PAE encoding for this statement.
133        """
134
135        return _pae(Envelope._TYPE, self._contents)

Represents an in-toto statement.

This type deals with opaque bytes to ensure that the encoding does not change, but Statements are internally checked for conformance against the JSON object layout defined in the in-toto attestation spec.

See: https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md

Statement(contents: bytes | sigstore.dsse._Statement)
 93    def __init__(self, contents: bytes | _Statement) -> None:
 94        """
 95        Construct a new Statement.
 96
 97        This takes an opaque `bytes` containing the statement; use
 98        `StatementBuilder` to manually construct an in-toto statement
 99        from constituent pieces.
100        """
101        if isinstance(contents, bytes):
102            self._contents = contents
103            try:
104                self._inner = _Statement.model_validate_json(contents)
105            except ValidationError:
106                raise Error("malformed in-toto statement")
107        else:
108            self._contents = contents.model_dump_json(by_alias=True).encode()
109            self._inner = contents

Construct a new Statement.

This takes an opaque bytes containing the statement; use StatementBuilder to manually construct an in-toto statement from constituent pieces.

class StatementBuilder:
138class StatementBuilder:
139    """
140    A builder-style API for constructing in-toto Statements.
141    """
142
143    def __init__(
144        self,
145        subjects: Optional[List[Subject]] = None,
146        predicate_type: Optional[str] = None,
147        predicate: Optional[Dict[str, Any]] = None,
148    ):
149        """
150        Create a new `StatementBuilder`.
151        """
152        self._subjects = subjects or []
153        self._predicate_type = predicate_type
154        self._predicate = predicate
155
156    def subjects(self, subjects: list[Subject]) -> StatementBuilder:
157        """
158        Configure the subjects for this builder.
159        """
160        self._subjects = subjects
161        return self
162
163    def predicate_type(self, predicate_type: str) -> StatementBuilder:
164        """
165        Configure the predicate type for this builder.
166        """
167        self._predicate_type = predicate_type
168        return self
169
170    def predicate(self, predicate: dict[str, Any]) -> StatementBuilder:
171        """
172        Configure the predicate for this builder.
173        """
174        self._predicate = predicate
175        return self
176
177    def build(self) -> Statement:
178        """
179        Build a `Statement` from the builder's state.
180        """
181        try:
182            stmt = _Statement(
183                type_="https://in-toto.io/Statement/v1",
184                subjects=self._subjects,
185                predicate_type=self._predicate_type,
186                predicate=self._predicate,
187            )
188        except ValidationError as e:
189            raise Error(f"invalid statement: {e}")
190
191        return Statement(stmt)

A builder-style API for constructing in-toto Statements.

StatementBuilder( subjects: Optional[List[Subject]] = None, predicate_type: Optional[str] = None, predicate: Optional[Dict[str, Any]] = None)
143    def __init__(
144        self,
145        subjects: Optional[List[Subject]] = None,
146        predicate_type: Optional[str] = None,
147        predicate: Optional[Dict[str, Any]] = None,
148    ):
149        """
150        Create a new `StatementBuilder`.
151        """
152        self._subjects = subjects or []
153        self._predicate_type = predicate_type
154        self._predicate = predicate

Create a new StatementBuilder.

def subjects( self, subjects: list[Subject]) -> StatementBuilder:
156    def subjects(self, subjects: list[Subject]) -> StatementBuilder:
157        """
158        Configure the subjects for this builder.
159        """
160        self._subjects = subjects
161        return self

Configure the subjects for this builder.

def predicate_type(self, predicate_type: str) -> StatementBuilder:
163    def predicate_type(self, predicate_type: str) -> StatementBuilder:
164        """
165        Configure the predicate type for this builder.
166        """
167        self._predicate_type = predicate_type
168        return self

Configure the predicate type for this builder.

def predicate(self, predicate: dict[str, typing.Any]) -> StatementBuilder:
170    def predicate(self, predicate: dict[str, Any]) -> StatementBuilder:
171        """
172        Configure the predicate for this builder.
173        """
174        self._predicate = predicate
175        return self

Configure the predicate for this builder.

def build(self) -> Statement:
177    def build(self) -> Statement:
178        """
179        Build a `Statement` from the builder's state.
180        """
181        try:
182            stmt = _Statement(
183                type_="https://in-toto.io/Statement/v1",
184                subjects=self._subjects,
185                predicate_type=self._predicate_type,
186                predicate=self._predicate,
187            )
188        except ValidationError as e:
189            raise Error(f"invalid statement: {e}")
190
191        return Statement(stmt)

Build a Statement from the builder's state.

class InvalidEnvelope(sigstore.errors.Error):
194class InvalidEnvelope(Error):
195    """
196    Raised when the associated `Envelope` is invalid in some way.
197    """

Raised when the associated Envelope is invalid in some way.

class Envelope:
200class Envelope:
201    """
202    Represents a DSSE envelope.
203
204    This class cannot be constructed directly; you must use `sign` or `from_json`.
205
206    See: <https://github.com/secure-systems-lab/dsse/blob/v1.0.0/envelope.md>
207    """
208
209    _TYPE = "application/vnd.in-toto+json"
210
211    def __init__(self, inner: _Envelope) -> None:
212        """
213        @private
214        """
215
216        self._inner = inner
217        self._verify()
218
219    def _verify(self) -> None:
220        """
221        Verify and load the Envelope.
222        """
223        if len(self._inner.signatures) != 1:
224            raise InvalidEnvelope("envelope must contain exactly one signature")
225
226        if not self._inner.signatures[0].sig:
227            raise InvalidEnvelope("envelope signature must be non-empty")
228
229        self._signature_bytes = self._inner.signatures[0].sig
230
231    @classmethod
232    def _from_json(cls, contents: bytes | str) -> Envelope:
233        """Return a DSSE envelope from the given JSON representation."""
234        inner = _Envelope().from_json(contents)
235        return cls(inner)
236
237    def to_json(self) -> str:
238        """
239        Return a JSON string with this DSSE envelope's contents.
240        """
241        return self._inner.to_json()
242
243    def __eq__(self, other: object) -> bool:
244        """Equality for DSSE envelopes."""
245
246        if not isinstance(other, Envelope):
247            return NotImplemented
248
249        return self._inner == other._inner
250
251    @property
252    def signature(self) -> bytes:
253        """Return the decoded bytes of the Envelope signature."""
254        return self._signature_bytes

Represents a DSSE envelope.

This class cannot be constructed directly; you must use sign or from_json.

See: https://github.com/secure-systems-lab/dsse/blob/v1.0.0/envelope.md

def to_json(self) -> str:
237    def to_json(self) -> str:
238        """
239        Return a JSON string with this DSSE envelope's contents.
240        """
241        return self._inner.to_json()

Return a JSON string with this DSSE envelope's contents.

signature: bytes
251    @property
252    def signature(self) -> bytes:
253        """Return the decoded bytes of the Envelope signature."""
254        return self._signature_bytes

Return the decoded bytes of the Envelope signature.