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 Envelope:
194    """
195    Represents a DSSE envelope.
196
197    This class cannot be constructed directly; you must use `sign` or `from_json`.
198
199    See: <https://github.com/secure-systems-lab/dsse/blob/v1.0.0/envelope.md>
200    """
201
202    _TYPE = "application/vnd.in-toto+json"
203
204    def __init__(self, inner: _Envelope) -> None:
205        """
206        @private
207        """
208
209        self._inner = inner
210
211    @classmethod
212    def _from_json(cls, contents: bytes | str) -> Envelope:
213        """Return a DSSE envelope from the given JSON representation."""
214        inner = _Envelope().from_json(contents)
215        return cls(inner)
216
217    def to_json(self) -> str:
218        """
219        Return a JSON string with this DSSE envelope's contents.
220        """
221        return self._inner.to_json()
222
223    def __eq__(self, other: object) -> bool:
224        """Equality for DSSE envelopes."""
225
226        if not isinstance(other, Envelope):
227            return NotImplemented
228
229        return self._inner == other._inner
230
231
232def _pae(type_: str, body: bytes) -> bytes:
233    """
234    Compute the PAE encoding for the given `type_` and `body`.
235    """
236
237    # See:
238    # https://github.com/secure-systems-lab/dsse/blob/v1.0.0/envelope.md
239    # https://github.com/in-toto/attestation/blob/v1.0/spec/v1.0/envelope.md
240    pae = f"DSSEv1 {len(type_)} {type_} ".encode()
241    pae += b" ".join([str(len(body)).encode(), body])
242    return pae
243
244
245def _sign(key: ec.EllipticCurvePrivateKey, stmt: Statement) -> Envelope:
246    """
247    Sign for the given in-toto `Statement`, and encapsulate the resulting
248    signature in a DSSE `Envelope`.
249    """
250    pae = stmt._pae()
251    _logger.debug(f"DSSE PAE: {pae!r}")
252
253    signature = key.sign(pae, ec.ECDSA(hashes.SHA256()))
254    return Envelope(
255        _Envelope(
256            payload=stmt._contents,
257            payload_type=Envelope._TYPE,
258            signatures=[Signature(sig=signature)],
259        )
260    )
261
262
263def _verify(key: ec.EllipticCurvePublicKey, evp: Envelope) -> bytes:
264    """
265    Verify the given in-toto `Envelope`, returning the verified inner payload.
266
267    This function does **not** check the envelope's payload type. The caller
268    is responsible for performing this check.
269    """
270
271    pae = _pae(evp._inner.payload_type, evp._inner.payload)
272
273    nsigs = len(evp._inner.signatures)
274    if nsigs != 1:
275        raise VerificationError(f"DSSE: exactly 1 signature allowed, got {nsigs}")
276
277    signature = evp._inner.signatures[0].sig
278
279    try:
280        key.verify(signature, pae, ec.ECDSA(hashes.SHA256()))
281    except InvalidSignature:
282        raise VerificationError("DSSE: invalid signature")
283
284    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].

model_fields: ClassVar[Dict[str, pydantic.fields.FieldInfo]] = {'name': FieldInfo(annotation=Union[Annotated[str, Strict(strict=True)], NoneType], required=True), 'digest': FieldInfo(annotation=RootModel[Dict[Union[Literal['sha256'], Literal['sha384'], Literal['sha512'], Literal['sha3_256'], Literal['sha3_384'], Literal['sha3_512']], str]], required=True)}

Metadata about the fields defined on the model, mapping of field names to [FieldInfo][pydantic.fields.FieldInfo] objects.

This replaces Model.__fields__ from Pydantic V1.

model_computed_fields: ClassVar[Dict[str, pydantic.fields.ComputedFieldInfo]] = {}

A dictionary of computed field names and their corresponding ComputedFieldInfo objects.

Inherited Members
pydantic.main.BaseModel
BaseModel
model_extra
model_fields_set
model_construct
model_copy
model_dump
model_dump_json
model_json_schema
model_parametrized_name
model_post_init
model_rebuild
model_validate
model_validate_json
model_validate_strings
dict
json
parse_obj
parse_raw
parse_file
from_orm
construct
copy
schema
schema_json
validate
update_forward_refs
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 Envelope:
194class Envelope:
195    """
196    Represents a DSSE envelope.
197
198    This class cannot be constructed directly; you must use `sign` or `from_json`.
199
200    See: <https://github.com/secure-systems-lab/dsse/blob/v1.0.0/envelope.md>
201    """
202
203    _TYPE = "application/vnd.in-toto+json"
204
205    def __init__(self, inner: _Envelope) -> None:
206        """
207        @private
208        """
209
210        self._inner = inner
211
212    @classmethod
213    def _from_json(cls, contents: bytes | str) -> Envelope:
214        """Return a DSSE envelope from the given JSON representation."""
215        inner = _Envelope().from_json(contents)
216        return cls(inner)
217
218    def to_json(self) -> str:
219        """
220        Return a JSON string with this DSSE envelope's contents.
221        """
222        return self._inner.to_json()
223
224    def __eq__(self, other: object) -> bool:
225        """Equality for DSSE envelopes."""
226
227        if not isinstance(other, Envelope):
228            return NotImplemented
229
230        return self._inner == other._inner

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:
218    def to_json(self) -> str:
219        """
220        Return a JSON string with this DSSE envelope's contents.
221        """
222        return self._inner.to_json()

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