├── LICENSE ├── MAINTAINERS.md ├── README.md ├── background.md ├── envelope.md ├── envelope.proto ├── hypothetical_signature_attack.ipynb ├── implementation ├── README.md ├── ecdsa.py └── signing_spec.py └── protocol.md /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MAINTAINERS.md: -------------------------------------------------------------------------------- 1 | # DSSE Maintainers 2 | 3 | This file lists, in no particular order, the maintainers of the DSSE 4 | specification, their GitHub username, and their affiliation. 5 | 6 | | Name | GitHub Username | Affiliation | 7 | |------|-----------------|-------------| 8 | | Justin Cappos | @JustinCappos | New York University | 9 | | Santiago Torres-Arias | @SantiagoTorres | Purdue University | 10 | | Mark Lodato | @MarkLodato | Google | 11 | | Tom Hennen | @TomHennen | Google | 12 | | Trishank Karthik Kuppusamy | @trishankatdatadog | Datadog | 13 | | Aditya Sirish A Yelgundhalli | @adityasaky | New York University | 14 | | Marina Moore | @mnm678 | New York University | 15 | | Joshua Lock | @joshuagl | Verizon | 16 | | Lukas Pühringer | @lukpueh | New York University | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DSSE: Dead Simple Signing Envelope 2 | 3 | Simple, foolproof standard for signing arbitrary data. 4 | 5 | ## Features 6 | 7 | * Supports arbitrary message encodings, not just JSON. 8 | * Authenticates the message *and* the type to avoid confusion attacks. 9 | * Avoids canonicalization to reduce attack surface. 10 | * Allows any desired crypto primitives or libraries. 11 | 12 | See [Background](background.md) for more information, including design 13 | considerations and rationale. 14 | 15 | ## What is it? 16 | 17 | Specifications for: 18 | 19 | * [Protocol](protocol.md) (*required*) 20 | * [Data structure](envelope.md), a.k.a. "Envelope" (*recommended*) 21 | * (pending #9) Suggested crypto primitives 22 | 23 | Out of scope (for now at least): 24 | 25 | * Key management / PKI / 26 | [exclusive ownership](https://www.bolet.org/~pornin/2005-acns-pornin+stern.pdf) 27 | 28 | ## Why not...? 29 | 30 | * Why not raw signatures? Too fragile. 31 | * Why not [JWS](https://tools.ietf.org/html/rfc7515)? Too many insecure 32 | implementations and features. 33 | * Why not [PASETO](https://paseto.io)? JSON-specific, too opinionated. 34 | * Why not the legacy TUF/in-toto signature scheme? JSON-specific, relies on 35 | canonicalization. 36 | 37 | See [Background](background.md) for further motivation. 38 | 39 | ## Who uses it? 40 | 41 | 44 | 45 | * [in-toto](https://in-toto.io) (pending implementation of [ITE-5](https://github.com/in-toto/ITE/blob/master/ITE/5/README.adoc)) 46 | * [TUF](https://theupdateframework.io) (pending implementation of [TAP-17](https://github.com/theupdateframework/taps/pull/138)) 47 | 48 | ## How can we use it? 49 | 50 | * There is a Python implementation in [this repository](implementation/). 51 | * There's a DSSE library for Go in [go-securesystemslib](https://github.com/secure-systems-lab/go-securesystemslib/tree/main/dsse). 52 | * SigStore includes a [Go implementation](https://github.com/sigstore/sigstore/tree/main/pkg/signature/dsse) 53 | that supports hardware tokens, cloud KMS systems, and more. 54 | 55 | ## Versioning 56 | 57 | The DSSE specification follows semantic versioning, and is released using Git 58 | tags. The `master` branch points to the latest release. Changes to the 59 | specification are submitted against the `devel` branch, and are merged into 60 | `master` when they are ready to be released. 61 | -------------------------------------------------------------------------------- /background.md: -------------------------------------------------------------------------------- 1 | # Background 2 | 3 | ## What is the intended use case? 4 | 5 | This can be used anywhere digital signatures are needed. 6 | 7 | The initial application is for signing software supply chain metadata in [TUF] 8 | and [in-toto]. 9 | 10 | ## Why do we need this? 11 | 12 | There is no other simple, foolproof signature scheme that we are aware of. 13 | 14 | * Raw signatures are too fragile. Every public key must be used for exactly 15 | one purpose over exactly one message type, lest the system be vulnerable to 16 | [confusion attacks](#motivation). In many cases, this results in a difficult 17 | key management problem. 18 | 19 | * [TUF] and [in-toto] currently use a scheme that avoids these problems but is 20 | JSON-specific and relies on [canonicalization](#motivation), which is an 21 | unnecessarily large attack surface. 22 | 23 | * [JWS], though popular, has a history of 24 | [vulnerable implementations](https://auth0.com/blog/critical-vulnerabilities-in-json-web-token-libraries/) 25 | due to the complexity and lack of specificity in the RFC, such as not 26 | verifying that `alg` matches the public key type or not verifying the root 27 | CA for `x5c`. It also requires a JSON library even if the payload is not 28 | JSON, though this is a minor issue. 29 | 30 | * [PASETO] is too opinionated to be used in all cases. For example, it 31 | mandates ed25519 signatures, which [Google Cloud KMS] does not support. 32 | PASETO also requires JSON payloads, which may not be desirable in all cases. 33 | 34 | The intent of this project is to define a minimal signature scheme that avoids 35 | these issues. 36 | 37 | ## Design requirements 38 | 39 | The [protocol](protocol.md): 40 | 41 | * MUST reduce the possibility of a client misinterpreting the payload (e.g. 42 | interpreting a JSON message as protobuf) 43 | * MUST support arbitrary payload types (e.g. not just JSON) 44 | * MUST support arbitrary crypto primitives, libraries, and key management 45 | systems (e.g. Tink vs openssl, Google KMS vs Amazon KMS) 46 | * SHOULD avoid depending on canonicalization for security 47 | * SHOULD NOT require unnecessary encoding (e.g. base64) 48 | * SHOULD NOT require the verifier to parse the payload before verifying 49 | 50 | The [data structure](envelope.md): 51 | 52 | * MUST include both message and signature(s) 53 | * NOTE: Detached signatures are supported by having the included message 54 | contain a cryptographic hash of the external data. 55 | * MUST support multiple signatures in one structure / file 56 | * SHOULD discourage users from reading the payload without verifying the 57 | signatures 58 | * SHOULD be easy to parse using common libraries (e.g. JSON) 59 | * SHOULD support a hint indicating what signing key was used 60 | 61 | ## Motivation 62 | 63 | There are two concerns with the current [in-toto]/[TUF] signature envelope. 64 | 65 | First, the signature scheme depends on [Canonical JSON], which has one practical 66 | problem and two theoretical ones: 67 | 68 | 1. Practical problem: It requires the payload to be JSON or convertible to 69 | JSON. While this happens to be true of in-toto and TUF today, a generic 70 | signature layer should be able to handle arbitrary payloads. 71 | 1. Theoretical problem 1: Two semantically different payloads could have the 72 | same canonical encoding. Although there are currently no known attacks on 73 | Canonical JSON, there have been attacks in the past on other 74 | canonicalization schemes 75 | ([example](https://latacora.micro.blog/2019/07/24/how-not-to.html#canonicalization)). 76 | It is safer to avoid canonicalization altogether. 77 | 1. Theoretical problem 2: It requires the verifier to parse the payload before 78 | verifying, which is both error-prone—too easy to forget to verify—and an 79 | unnecessarily increased attack surface. 80 | 81 | The preferred solution is to transmit the encoded byte stream exactly as it was 82 | signed, which the verifier verifies before parsing. This is what is done in 83 | [JWS] and [PASETO], for example. 84 | 85 | Second, the scheme does not include an authenticated "context" indicator to 86 | ensure that the signer and verifier interpret the payload in the same exact way. 87 | For example, if in-toto were extended to support CBOR and protobuf encoding, the 88 | signer could get a CI/CD system to produce a CBOR message saying X and then a 89 | verifier to interpret it as a protobuf message saying Y. While we don't know of 90 | an exploitable attack on in-toto or TUF today, potential changes could introduce 91 | such a vulnerability. The signature scheme should be resilient against these 92 | classes of attacks. See [example attack](hypothetical_signature_attack.ipynb) 93 | for more details. 94 | 95 | ## Reasoning 96 | 97 | Our goal was to create a signature envelope that is as simple and foolproof as 98 | possible. Alternatives such as [JWS] are extremely complex and error-prone, 99 | while others such as [PASETO] are overly specific. (Both are also 100 | JSON-specific.) We believe our proposal strikes the right balance of simplicity, 101 | usefulness, and security. 102 | 103 | Rationales for specific decisions: 104 | 105 | - Why use base64 for payload and sig? 106 | 107 | - Because JSON strings do not allow binary data, so we need to either 108 | encode the data or escape it. Base64 is a standard, reasonably 109 | space-efficient way of doing so. Protocols that have a first-class 110 | concept of "bytes", such as protobuf or CBOR, do not need to use base64. 111 | 112 | - Why sign raw bytes rather than base64 encoded bytes (as per JWS)? 113 | 114 | - Because it's simpler. Base64 is only needed for putting binary data in a 115 | text field, such as JSON. In other formats, such as protobuf or CBOR, 116 | base64 isn't needed at all. 117 | 118 | - Why does payloadType need to be signed? 119 | 120 | - See [Motivation](#motivation). 121 | 122 | - Why use a pre-authentication encoding (PAE)? 123 | 124 | - Because we need an unambiguous way of serializing two fields, 125 | payloadType and payload. PASETO already solved this problem by 126 | developing its PAE, which is where we got the idea and the name. 127 | [ed25519ctx] uses the same idea, though it was developed independently. 128 | 129 | - Why not use PASETO's PAE? 130 | 131 | - Originally we did, but the minor difficulty of working with binary 132 | encoding led us to develop a simpler ASCII-based PAE. While we were at 133 | it, we switched to using a fixed number of inputs and added a version 134 | string ("DSSEv1") to allow for future changes. 135 | ([more info](https://github.com/secure-systems-lab/dsse/issues/27)) 136 | 137 | - Why not stay backwards compatible by requiring the payload to always be JSON 138 | with a "_type" field? Then if you want a non-JSON payload, you could simply 139 | have a field that contains the real payload, e.g. `{"_type":"my-thing", 140 | "value":"base64…"}`. 141 | 142 | 1. It encourages users to add a "_type" field to their payload, which in 143 | turn: 144 | - (a) Ties the payload type to the authentication type. Ideally the 145 | two would be independent. 146 | - (b) May conflict with other uses of that same field. 147 | - (c) May require the user to specify type multiple times with 148 | different field names, e.g. with "@context" for 149 | [JSON-LD](https://json-ld.org/). 150 | 2. It would incur double base64 encoding overhead for non-JSON payloads. 151 | 3. It is more complex than PAE. 152 | 153 | ## Backwards Compatibility 154 | 155 | Backwards compatibility with the old [in-toto]/[TUF] format will be handled by 156 | the application and explained in the corresponding application-specific change 157 | proposal, namely [ITE-5](https://github.com/in-toto/ITE/pull/13) for in-toto and 158 | via the principles laid out in 159 | [TAP-14](https://github.com/theupdateframework/taps/blob/master/tap14.md) for 160 | TUF. 161 | 162 | Verifiers can differentiate between the 163 | [old](https://github.com/in-toto/specification/blob/d416c1f334ac6b581f75c0fa65125fb434d7a610/in-toto-spec.md#42-file-formats-general-principles) 164 | and new envelope format by detecting the presence of the `payload` field (new 165 | format) vs `signed` field (old format). 166 | 167 | [Canonical JSON]: http://wiki.laptop.org/go/Canonical_JSON 168 | [ed25519ctx]: https://www.cryptologie.net/article/497/eddsa-ed25519-ed25519-ietf-ed25519ph-ed25519ctx-hasheddsa-pureeddsa-wtf/ 169 | [Google Cloud KMS]: https://cloud.google.com/security-key-management 170 | [in-toto]: https://in-toto.io 171 | [JWS]: https://tools.ietf.org/html/rfc7515 172 | [PASETO]: https://github.com/paseto-standard/paseto-spec/blob/master/docs/01-Protocol-Versions/Version2.md#sign 173 | [TUF]: https://theupdateframework.io 174 | -------------------------------------------------------------------------------- /envelope.md: -------------------------------------------------------------------------------- 1 | # DSSE Envelope 2 | 3 | May 10, 2024 4 | 5 | Version 1.0.2 6 | 7 | This document describes the recommended data structure for storing DSSE 8 | signatures, which we call the "JSON Envelope". For the protocol/algorithm, see 9 | [Protocol](protocol.md). 10 | 11 | ## Standard JSON envelope 12 | 13 | See [envelope.proto](envelope.proto) for a formal schema. (Protobuf is used only 14 | to define the schema. JSON is the only recommended encoding.) 15 | 16 | The standard data structure for storing a signed message is a JSON message of 17 | the following form, called the "JSON envelope": 18 | 19 | ```json 20 | { 21 | "payload": "", 22 | "payloadType": "", 23 | "signatures": [{ 24 | "keyid": "", 25 | "sig": "" 26 | }] 27 | } 28 | ``` 29 | 30 | See [Protocol](protocol.md) for a definition of parameters and functions. 31 | 32 | Base64() is [Base64 encoding](https://tools.ietf.org/html/rfc4648), transforming 33 | a byte sequence to a unicode string. Either standard or URL-safe encoding is 34 | allowed. 35 | 36 | ### Multiple signatures 37 | 38 | An envelope MAY have more than one signature, which is equivalent to separate 39 | envelopes with individual signatures. 40 | 41 | ```json 42 | { 43 | "payload": "", 44 | "payloadType": "", 45 | "signatures": [{ 46 | "keyid": "", 47 | "sig": "" 48 | }, { 49 | "keyid": "", 50 | "sig": "" 51 | }] 52 | } 53 | ``` 54 | 55 | ### Parsing rules 56 | 57 | * The following fields are REQUIRED and MUST be set, even if empty: `payload`, 58 | `payloadType`, `signature`, `signature.sig`. 59 | * The following fields are OPTIONAL and MAY be unset: `signature.keyid`. 60 | An unset field MUST be treated the same as set-but-empty. 61 | * Producers, or future versions of the spec, MAY add additional fields. 62 | Consumers MUST ignore unrecognized fields. 63 | 64 | ## Other data structures 65 | 66 | The standard envelope is JSON message with an explicit `payloadType`. 67 | Optionally, applications may encode the signed message in other methods without 68 | invalidating the signature: 69 | 70 | - An encoding other than JSON, such as CBOR or protobuf. 71 | - Use a default `payloadType` if omitted and/or code `payloadType` as a 72 | shorter string or enum. 73 | 74 | At this point we do not standardize any other encoding. If a need arises, we may 75 | do so in the future. 76 | 77 | ## Security considerations 78 | 79 | The following advisories are relevant to all envelope formats, not just the 80 | standard JSON envelope. 81 | 82 | **Important:** Implementations MUST ensure that the same payload bytes that are 83 | verified are the ones sent to the application layer. In particular, 84 | implementations MUST NOT re-parse the envelope after verification to pull out 85 | the payload. Failure to adhere to this requirement can lead to security 86 | vulnerabilities. 87 | -------------------------------------------------------------------------------- /envelope.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package io.intoto; 4 | 5 | // An authenticated message of arbitrary type. 6 | message Envelope { 7 | // Message to be signed. (In JSON, this is encoded as base64.) 8 | // REQUIRED. 9 | bytes payload = 1; 10 | 11 | // String unambiguously identifying how to interpret payload. 12 | // REQUIRED. 13 | string payloadType = 2; 14 | 15 | // Signature over: 16 | // PAE(type, payload) 17 | // Where PAE is defined as: 18 | // PAE(type, payload) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(payload) + SP + payload 19 | // + = concatenation 20 | // SP = ASCII space [0x20] 21 | // "DSSEv1" = ASCII [0x44, 0x53, 0x53, 0x45, 0x76, 0x31] 22 | // LEN(s) = ASCII decimal encoding of the byte length of s, with no leading zeros 23 | // REQUIRED (length >= 1). 24 | repeated Signature signatures = 3; 25 | } 26 | 27 | message Signature { 28 | // Signature itself. (In JSON, this is encoded as base64.) 29 | // REQUIRED. 30 | bytes sig = 1; 31 | 32 | // *Unauthenticated* hint identifying which public key was used. 33 | // OPTIONAL. 34 | string keyid = 2; 35 | } 36 | -------------------------------------------------------------------------------- /hypothetical_signature_attack.ipynb: -------------------------------------------------------------------------------- 1 | {"nbformat":4,"nbformat_minor":0,"metadata":{"colab":{"name":"Copy of ITE-5: Hypothetical In-Toto Signature Attack.ipynb","provenance":[{"file_id":"https://github.com/MarkLodato/ITE/blob/ite-5/ITE/5/hypothetical_signature_attack.ipynb","timestamp":1601319956961}],"collapsed_sections":["yOiiQrZZSdlg"],"authorship_tag":"ABX9TyN4m90Onm73qJCQQe416IXO"},"kernelspec":{"name":"python3","display_name":"Python 3"}},"cells":[{"cell_type":"markdown","metadata":{"id":"ll0X3N1LtM_p"},"source":["##### Copyright 2020 Google LLC\n","\n","Licensed under the Apache License, Version 2.0 (the \"License\");\n","you may not use this file except in compliance with the License.\n","You may obtain a copy of the License at\n","\n","https://www.apache.org/licenses/LICENSE-2.0\n","\n","Unless required by applicable law or agreed to in writing, software\n","distributed under the License is distributed on an \"AS IS\" BASIS,\n","WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n","See the License for the specific language governing permissions and\n","limitations under the License."]},{"cell_type":"markdown","metadata":{"id":"XUErxuD6w0_W"},"source":["## Abstract\n","\n","This proof-of-concept attack shows the need for any signature scheme to have an authenticated \"context\" field indicating how to interpret the payload.\n","\n","_Author: Mark Lodato, Google, _ \n","_Date: September 2020_\n","\n","(To edit, [open this doc in Colab](https://colab.research.google.com/github/MarkLodato/ITE/blob/ite-5/ITE/5/hypothetical_signature_attack.ipynb).)"]},{"cell_type":"markdown","metadata":{"id":"_17nT3k6p19J"},"source":["## Overview\n","\n","In any cryptographic signature wrapper, the payload must be unambiguously interpreted, such that the signer and verifier are guaranteed to interpret the payload identically.\n","\n","Currently, in-toto and TUF achieved this by requiring that the payload be JSON and that the JSON have a `_type` key that indicates how it is used. Thus, there is only one way for the verifier to interpret the bitstream that the signer signed.\n","\n","However, there are ongoing discussions about (1) generalizing the signature wrapper so that it is no longer in-toto/TUF-specific, and (2) supporting in-toto payloads other than JSON. If either of these happen, then it will no longer be feasible to require the payload to be JSON. Instead, the signature wrapper **must** include some authenticated \"context\" indicator that describes how to interpret the payload.\n","\n","If the signature scheme does *not* include an authenticated context indicator, then an attacker can take a legitimate signed message of type X and get the victim to verify and interpret it as type Y.\n","\n","What follows is a worked example showing how it can happen in a realistic scenario.\n","\n","\n","\n","\n"]},{"cell_type":"markdown","metadata":{"id":"jKjOIpaez5J-"},"source":["## Scenario"]},{"cell_type":"markdown","metadata":{"id":"X4CrxasPz5_R"},"source":["This proof-of-concept assumes the following.\n","\n","(1) In-toto has been extended to support three different encodings of the link format: JSON, [CBOR](https://en.wikipedia.org/wiki/CBOR), and [Protobuf](https://github.com/grafeas/grafeas/blob/63aff549c1813170558b49e40f41147fd31ad1e3/proto/v1beta1/intoto.proto). In this scenario, the cryptographic wrapper has three fields:\n","\n","* `payload`: The serialized JSON, CBOR, or Protobuf byte stream.\n","* `payloadType`: How to interpret `payload`. One of \"JSON\", \"CBOR\", or \"Protobuf\".\n","* `signatures`: Cryptographic signatures over `payload` but **not** `payloadType`. **This is the problem.**\n","\n","Note: In this demo, the wrapper is always JSON, both `payload` and `signatures.sig` are encoded in base64, and the signature is over the raw bits prior to base64 encoding. However, this is immaterial to the attack.\n","\n","(2) There exists a trusted CI/CD service that allows callers to perform arbitrary build requests and returns a signed in-toto link file. This mirrors how system such as GitHub Actions or [Debian rebuilders](https://wiki.debian.org/ReproducibleBuilds) work. In our scenario, the build interface takes three user-defined parameters:\n","\n","* `command`: The shell command to run.\n","* `encoding`: The `payloadType` to return.\n","\n","**Problem:** An attacker can trick the CI/CD system to sign arbitrary messages. \n","\n","Suppose the following is a **legitimate** link file:\n","\n","```json\n","{\n"," \"command\": \"echo 'hello world'\",\n"," \"products\": { \"stdout\": { \"sha256\": \"a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447\" } },\n"," \"materials\": {},\n"," \"_type\": \"link\"\n","}\n","```\n","\n","An attacker can instead get the CI/CD system to **falsely** sign:\n","\n","```json\n","{\n"," \"command\": \"echo 'hello world'\",\n"," \"products\": { \"stdout\": { \"sha256\": \"badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb\" } },\n"," \"materials\": {},\n"," \"_type\": \"link\"\n","}\n","```\n","\n","This can then be used by the attacker to get malicious file with sha256 hash \"badbad...\" to be accepted by an in-toto verifier."]},{"cell_type":"markdown","metadata":{"id":"CIF11AmzXJuQ"},"source":["## Outline of attack\n","\n","1. Construct a target payload T in protobuf format that we want the victim to consume.\n","2. Send a carefully crafted build request that results CI/CD returning a signed CBOR-type link file, such that the payload is interpreted as P when type is CBOR but T when type is protobuf.\n","3. Modify the `payloadType` field to say `Protobuf` instead of `CBOR`. This does not invalidate the signature because the `payloadType` is unauthenticated.\n","4. Send the modified link file to the victim. They will interpret the payload as T, even though the CI/CD system intended it to be interpreted as P."]},{"cell_type":"markdown","metadata":{"id":"qwTiWE8ARQVy"},"source":["## Mock implementations\n","\n","This demo uses the following mock implementations."]},{"cell_type":"markdown","metadata":{"id":"mFLCMDzrBNU5"},"source":["### Dependencies"]},{"cell_type":"code","metadata":{"id":"u-L_AAadzUWn"},"source":["!curl -o intoto.proto -sS https://raw.githubusercontent.com/grafeas/grafeas/63aff549c1813170558b49e40f41147fd31ad1e3/proto/v1beta1/intoto.proto\n","!protoc intoto.proto --python_out=."],"execution_count":null,"outputs":[]},{"cell_type":"code","metadata":{"id":"eG7IQqXSri-U"},"source":["!pip install cbor pycryptodome"],"execution_count":null,"outputs":[]},{"cell_type":"markdown","metadata":{"id":"r1QZavg7KHIa"},"source":["### Crypto implementation\n"]},{"cell_type":"code","metadata":{"id":"9us8oiwyDIrZ"},"source":["from Crypto.Hash import SHA256\n","from Crypto.PublicKey import ECC\n","from Crypto.Signature import DSS\n","\n","secret_key = ECC.generate(curve='P-256')\n","public_key = secret_key.public_key()"],"execution_count":null,"outputs":[]},{"cell_type":"code","metadata":{"id":"jQQNYC5OJmXa"},"source":["def _PubkeySign(message: bytes) -> bytes:\n"," \"\"\"Returns the signature of `message`.\"\"\"\n"," h = SHA256.new(message)\n"," return DSS.new(secret_key, 'fips-186-3').sign(h)\n","\n","def _PubkeyVerify(message: bytes, signature: bytes) -> bool:\n"," \"\"\"Returns true if `message` was signed by `signature`.\"\"\"\n"," h = SHA256.new(message)\n"," try:\n"," DSS.new(public_key, 'fips-186-3').verify(h, signature)\n"," return True\n"," except ValueError:\n"," return False"],"execution_count":null,"outputs":[]},{"cell_type":"markdown","metadata":{"id":"ONTsGjB222si"},"source":["Tests to make sure it works correctly:"]},{"cell_type":"code","metadata":{"id":"A8Ip5uBNKLVe"},"source":["signature = _PubkeySign(b'good')\n","assert _PubkeyVerify(b'good', signature)\n","assert not _PubkeyVerify(b'bad', signature)\n"],"execution_count":null,"outputs":[]},{"cell_type":"markdown","metadata":{"id":"XPkPlVXaNIrN"},"source":["### CI/CD implementation"]},{"cell_type":"code","metadata":{"id":"pBAIZhaNosy0"},"source":["import base64, cbor, hashlib, json, subprocess, tempfile\n","\n","def Build(command, encoding):\n"," \"\"\"Runs `command` and returns a link file of the given `encoding`.\n"," \n"," WARNING: This isn't actually safe to do in a real CI/CD system. We're doing it\n"," here because it's just a demo where we trust the command.\n"," \"\"\"\n"," with tempfile.TemporaryDirectory() as directory:\n"," result = subprocess.run(command, shell=True, cwd=directory, check=True,\n"," stdout=subprocess.PIPE)\n"," link = {\n"," \"command\": command,\n"," \"materials\": {},\n"," \"products\": {\n"," 'stdout' : {\n"," 'sha256' : hashlib.sha256(result.stdout).hexdigest()\n"," }\n"," },\n"," \"byproducts\": {},\n"," \"_type\": \"link\",\n"," }\n"," if encoding == 'CBOR':\n"," payload = cbor.dumps(link)\n"," else:\n"," raise NotImplementedError('Encoding \"%s\" not implemented in this demo' % encoding)\n"," signature = _PubkeySign(payload)\n"," wrapper = {\n"," \"payload\": base64.b64encode(payload).decode('utf-8'),\n"," \"payloadType\": encoding,\n"," \"signatures\": [{\"sig\": base64.b64encode(signature).decode('utf-8')}],\n"," }\n"," return json.dumps(wrapper)"],"execution_count":null,"outputs":[]},{"cell_type":"markdown","metadata":{"id":"LoShcMq3Ptkb"},"source":["Examples showing the wrapper and payload:"]},{"cell_type":"code","metadata":{"id":"rL_tZcvWVIkM","executionInfo":{"status":"ok","timestamp":1601045411246,"user_tz":240,"elapsed":11073,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"bb2bc132-2eca-47a5-bfae-924f4d6ba9bb","colab":{"base_uri":"https://localhost:8080/"}},"source":["link = Build('echo \"hello world\"', 'CBOR')\n","json.loads(link)"],"execution_count":null,"outputs":[{"output_type":"execute_result","data":{"text/plain":["{'payload': 'pWdjb21tYW5kcmVjaG8gImhlbGxvIHdvcmxkImltYXRlcmlhbHOgaHByb2R1Y3RzoWZzdGRvdXShZnNoYTI1NnhAYTk0ODkwNGYyZjBmNDc5YjhmODE5NzY5NGIzMDE4NGIwZDJlZDFjMWNkMmExZWMwZmI4NWQyOTlhMTkyYTQ0N2pieXByb2R1Y3RzoGVfdHlwZWRsaW5r',\n"," 'payloadType': 'CBOR',\n"," 'signatures': [{'sig': 'x5Ni6nWD6gaBHZSnN9tZHOGm3smSJY2ZAberyHHGa9WQepXOOb3UdqtJSuxyr7XgtZVZe/pCqk3xqxnhnIE8UQ=='}]}"]},"metadata":{"tags":[]},"execution_count":7}]},{"cell_type":"code","metadata":{"id":"mLR5sBpNrQsg","executionInfo":{"status":"ok","timestamp":1601045411248,"user_tz":240,"elapsed":11069,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"9b49510a-bb5d-4a61-cc5a-02f04f689cac","colab":{"base_uri":"https://localhost:8080/"}},"source":["cbor.loads(base64.b64decode(json.loads(link)['payload']))"],"execution_count":null,"outputs":[{"output_type":"execute_result","data":{"text/plain":["{'_type': 'link',\n"," 'byproducts': {},\n"," 'command': 'echo \"hello world\"',\n"," 'materials': {},\n"," 'products': {'stdout': {'sha256': 'a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447'}}}"]},"metadata":{"tags":[]},"execution_count":8}]},{"cell_type":"code","metadata":{"id":"_FhQfZsqPZLd","executionInfo":{"status":"ok","timestamp":1601045411250,"user_tz":240,"elapsed":11064,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"d5048cbc-f707-440c-d094-4a55921b95f2","colab":{"base_uri":"https://localhost:8080/"}},"source":["link = Build('echo \"something else\"', 'CBOR')\n","cbor.loads(base64.b64decode(json.loads(link)['payload']))"],"execution_count":null,"outputs":[{"output_type":"execute_result","data":{"text/plain":["{'_type': 'link',\n"," 'byproducts': {},\n"," 'command': 'echo \"something else\"',\n"," 'materials': {},\n"," 'products': {'stdout': {'sha256': 'a1621be95040239ee14362c16e20510ddc20f527d772d823b2a1679b33f5cd74'}}}"]},"metadata":{"tags":[]},"execution_count":9}]},{"cell_type":"markdown","metadata":{"id":"IDcWni4RsHua"},"source":["### Verifier implementation"]},{"cell_type":"markdown","metadata":{"id":"CxjvWqjcsNkZ"},"source":["Instead of writing an actual layout, we simply have the verifier print out the payload. It is sufficient to demonstrate the attack if one signed payload can be interpreted in two different ways."]},{"cell_type":"code","metadata":{"id":"X4TdPm33sdOx"},"source":["import base64, cbor, json, intoto_pb2, pprint\n","\n","def VerifyAndPrint(link_serialized):\n"," \"\"\"Verifies the signature and then prints the payload.\n","\n"," NOTE: The schema differs slightly between JSON/CBOR and Proto formats.\n"," This function does not convert between them.\n"," \"\"\"\n"," wrapper = json.loads(link_serialized)\n"," payload_bytes = base64.b64decode(wrapper['payload'])\n"," signature = base64.b64decode(wrapper['signatures'][0]['sig'])\n"," if not _PubkeyVerify(payload_bytes, signature):\n"," print(\"Bad signature\")\n"," else:\n"," print(\"Good signature\")\n"," link = DECODERS[wrapper['payloadType']](payload_bytes)\n"," pprint.pprint(link)\n","\n","DECODERS = {\n"," 'JSON': json.loads,\n"," 'CBOR': cbor.loads,\n"," 'Protobuf': intoto_pb2.Link.FromString,\n","}"],"execution_count":null,"outputs":[]},{"cell_type":"code","metadata":{"id":"QjuytLUWN4Sk","executionInfo":{"status":"ok","timestamp":1601045411255,"user_tz":240,"elapsed":11060,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"a1b2b2c3-181e-48f1-c31e-0d9afb3d8726","colab":{"base_uri":"https://localhost:8080/"}},"source":["VerifyAndPrint(Build('echo \"hello world\"', 'CBOR'))"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Good signature\n","{'_type': 'link',\n"," 'byproducts': {},\n"," 'command': 'echo \"hello world\"',\n"," 'materials': {},\n"," 'products': {'stdout': {'sha256': 'a948904f2f0f479b8f8197694b30184b0d2ed1c1cd2a1ec0fb85d299a192a447'}}}\n"],"name":"stdout"}]},{"cell_type":"code","metadata":{"id":"xhL224vb7SCJ","executionInfo":{"status":"ok","timestamp":1601045411259,"user_tz":240,"elapsed":11057,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"03858e0d-d11c-45d6-fdf0-23dbbfc3573b","colab":{"base_uri":"https://localhost:8080/"}},"source":["VerifyAndPrint(Build('echo \"goodbye world\"', 'CBOR'))"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Good signature\n","{'_type': 'link',\n"," 'byproducts': {},\n"," 'command': 'echo \"goodbye world\"',\n"," 'materials': {},\n"," 'products': {'stdout': {'sha256': '8ef67e7cf7addbb1946c13778f51f8bfa3ee261b1016f6828796dd9fca632fc4'}}}\n"],"name":"stdout"}]},{"cell_type":"code","metadata":{"id":"9Uq0h9-y7aV4","executionInfo":{"status":"ok","timestamp":1601045411263,"user_tz":240,"elapsed":11054,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"acd2a893-406d-417e-e018-da8943714914","colab":{"base_uri":"https://localhost:8080/"}},"source":["orig = Build('echo \"hello world\"', 'CBOR')\n","link = json.loads(orig)\n","link['payload'] = 'x' + link['payload'][1:]\n","VerifyAndPrint(json.dumps(link))"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Bad signature\n"],"name":"stdout"}]},{"cell_type":"markdown","metadata":{"id":"FvxotJilw9RA"},"source":["## Step 1: Construct target payload\n","\n","First, we construct our target payload in protobuf format. This is what we want the victim to accept."]},{"cell_type":"code","metadata":{"id":"H68z0n7TPD35","executionInfo":{"status":"ok","timestamp":1601045411265,"user_tz":240,"elapsed":11049,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"55d1992d-f8fd-42b9-b1a5-7248604c505a","colab":{"base_uri":"https://localhost:8080/"}},"source":["%%writefile payload.textproto\n","effective_command: 'echo \"hello world\"'\n","products {\n"," resource_uri: \"stdout\"\n"," hashes {\n"," sha256: \"badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb\"\n"," }\n","}"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Writing payload.textproto\n"],"name":"stdout"}]},{"cell_type":"code","metadata":{"id":"sHlqSDxYzszi","executionInfo":{"status":"ok","timestamp":1601045411457,"user_tz":240,"elapsed":11234,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"506c9941-6721-4e36-86e9-fff715a06e61","colab":{"base_uri":"https://localhost:8080/"}},"source":["import intoto_pb2\n","from google.protobuf import text_format\n","with open('payload.textproto') as f:\n"," target_payload = text_format.Parse(f.read(), intoto_pb2.Link()).SerializeToString()\n","target_payload"],"execution_count":null,"outputs":[{"output_type":"execute_result","data":{"text/plain":["b'\\n\\x12echo \"hello world\"\\x1aL\\n\\x06stdout\\x12B\\n@badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb'"]},"metadata":{"tags":[]},"execution_count":15}]},{"cell_type":"markdown","metadata":{"id":"yqfJa9-0RUak"},"source":["## Step 2: Construct build request\n","\n","Next, we need to craft a build command that results in the overall CBOR file being interpreted by the victim as our payload protobuf."]},{"cell_type":"markdown","metadata":{"id":"yOiiQrZZSdlg"},"source":["### Proto Parser tool\n","\n","The following tool will be useful for visualizing protobufs."]},{"cell_type":"code","metadata":{"id":"k7-BAdOPSUCt","executionInfo":{"status":"ok","timestamp":1601045411459,"user_tz":240,"elapsed":11229,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"12ff5d9b-f63e-4d32-dae8-317b96c2a594","colab":{"base_uri":"https://localhost:8080/"}},"source":["%%writefile proto_parser.py\n","#!/usr/bin/python3\n","\"\"\"Parses a raw proto wire-format file and shows how each byte is interpeted.\n","\n","USAGE: ./proto_parser.py [OPTIONS]\n","\n","Limitations (a.k.a. TODOs):\n","- Does not parse nested message types.\n","\"\"\"\n","\n","import io\n","import math\n","import shutil\n","import struct\n","\n","from typing import IO, Optional\n","\n","\n","class ProtobufValue:\n","\n"," def __init__(self, buf: bytes):\n"," self.buffer = buf\n","\n"," def format_buffer(self) -> str:\n"," a = []\n"," for value in bytearray(self.buffer):\n"," if value < 0x20 or value == 0x7f:\n"," # Unicode control code pictures\n"," # http://www.unicode.org/charts/nameslist/n_2400.html\n"," char = chr(0x2400 + value)\n"," elif value == 0x20:\n"," char = '\\u2423' # open box for space\n"," elif value >= 0x80:\n"," char = '\\u2426' # reverse question mark\n"," else:\n"," char = chr(value)\n"," a.append(char)\n"," return ''.join(a)\n","\n"," def type_name(self):\n"," return self.TYPE_NAME\n","\n","\n","class Varint(ProtobufValue):\n"," TYPE_NAME = 'varint'\n","\n"," def __init__(self, buf: bytes, value: int):\n"," super().__init__(buf)\n"," self.value = value\n","\n"," def format_value(self) -> str:\n"," # TODO: also print signed int, sint (zigzag), hex\n"," return '%d' % self.value\n","\n"," @classmethod\n"," def read(cls, f: IO[bytes], allow_missing=False) -> 'Optional[Varint]':\n"," buf = bytearray()\n"," value = 0\n"," while True:\n"," if len(buf) > 10:\n"," raise ValueError('varint exceeded maximum size')\n"," byte = f.read(1)\n"," if not byte:\n"," if allow_missing and not buf:\n"," return None\n"," raise ValueError('end of input while reading varint')\n"," buf.extend(byte)\n"," b = buf[-1]\n"," value |= (b & 0x7f) << (7 * (len(buf) - 1))\n"," if not (b & 0x80):\n"," break\n"," return cls(bytes(buf), value)\n","\n","\n","class FixedBase(ProtobufValue):\n"," # Subclasses must define: TYPE_NAME, byte_size, struct_int_code\n","\n"," def format_value(self) -> str:\n"," # TODO: also print signed int, hex, double\n"," return str(struct.unpack(self.struct_int_code, self.buffer)[0])\n","\n"," @classmethod\n"," def read(cls, f: IO[bytes]) -> 'FixedBase':\n"," b = f.read(cls.size)\n"," if len(b) != cls.size:\n"," raise ValueError('end of input while reading %s' % cls.TYPE_NAME)\n"," return cls(b)\n","\n","\n","class Fixed64(FixedBase):\n"," TYPE_NAME = 'fixed64'\n"," size = 8\n"," struct_int_code = 'L'\n","\n","\n","class Fixed32(FixedBase):\n"," TYPE_NAME = 'fixed32'\n"," size = 4\n"," struct_int_code = 'I'\n","\n","\n","class LengthDelimited(ProtobufValue):\n"," TYPE_NAME = 'length-delim'\n","\n"," def __init__(self, buf: bytes, value: bytes):\n"," super().__init__(buf)\n"," self.value = value\n","\n"," def format_value(self) -> str:\n"," # TODO: truncate\n"," if len(self.value) < 23:\n"," s = self.value.decode('unicode-escape')\n"," else:\n"," s = '%s...%s' % (self.value[:20].decode('unicode-escape'),\n"," self.value[-20:].decode('unicode-escape'))\n"," return 'length=%d value=%s' % (len(self.value), s)\n","\n"," def type_name(self):\n"," return 'length={}'.format(len(self.value))\n","\n"," @classmethod\n"," def read(cls, f: IO[bytes]) -> 'LengthDelimited':\n"," length = Varint.read(f)\n"," value = f.read(length.value)\n"," if len(value) != length.value:\n"," raise ValueError('expected %d bytes for length-delimited field; got %d' %\n"," (length.value, len(value)))\n"," return cls(length.buffer + value, value)\n","\n","\n","class StartGroup(ProtobufValue):\n"," TYPE_NAME = 'start-group'\n","\n"," def format_value(self) -> str:\n"," return ''\n","\n"," @classmethod\n"," def read(cls, f: IO[bytes]) -> 'StartGroup':\n"," return cls(b'')\n","\n","\n","class EndGroup(StartGroup):\n"," TYPE_NAME = 'end-group'\n","\n","\n","class Field:\n","\n"," def __init__(self, tag: Varint, start_pos: int, field_number: int,\n"," field_value: ProtobufValue):\n"," self.tag = tag\n"," self.start_pos = start_pos\n"," self.field_number = field_number\n"," self.field_value = field_value\n","\n"," def type_name(self) -> str:\n"," return self.field_value.type_name()\n","\n"," def format_buffer(self) -> str:\n"," return '{} {}'.format(self.tag.format_buffer(),\n"," self.field_value.format_buffer())\n","\n","\n","class BadField:\n","\n"," def __init__(self, tag: Varint, start_pos: int, field_number: int,\n"," error: str):\n"," self.tag = tag\n"," self.start_pos = start_pos\n"," self.field_number = field_number\n"," self.error = error\n","\n"," def type_name(self) -> str:\n"," return 'error'\n","\n"," def format_buffer(self) -> str:\n"," return '{} <{}>'.format(self.tag.format_buffer(), self.error)\n","\n","\n","TYPE_MAP = {\n"," 0: Varint,\n"," 1: Fixed64,\n"," 2: LengthDelimited,\n"," 3: StartGroup,\n"," 4: EndGroup,\n"," 5: Fixed32,\n","}\n","\n","\n","def decode(f: IO[bytes]):\n"," while True:\n"," start_pos = f.tell()\n"," try:\n"," tag = Varint.read(f, allow_missing=True)\n"," except ValueError as e:\n"," # TODO: would be nice to keep buffer of error\n"," tag = Varint(b'', 0) # dummy value\n"," yield BadField(tag, start_pos, -1, str(e))\n"," return\n"," if tag is None:\n"," return\n"," field_number, field_type = tag.value >> 3, tag.value & 7\n"," try:\n"," type_class = TYPE_MAP[field_type]\n"," except KeyError:\n"," yield BadField(tag, start_pos, field_number,\n"," 'invalid field type: %s' % field_type)\n"," return\n"," try:\n"," field_value = type_class.read(f)\n"," except ValueError as e:\n"," yield BadField(tag, start_pos, field_number, str(e))\n"," return\n"," yield Field(tag, start_pos, field_number, field_value)\n","\n","\n","def decode_and_print(data: bytes,\n"," *,\n"," width=None,\n"," limit=None,\n"," header=True) -> None:\n"," if width is None:\n"," width = 80\n"," if header:\n"," print('{:4} {:4} {:12} {}'.format('Pos.', 'Fld#', 'Type', 'Value'))\n"," for i, t in enumerate(decode(io.BytesIO(data))):\n"," line = '{pos:04X} {field_num:4d} {field_type:12} {buffer}'.format(\n"," pos=t.start_pos,\n"," field_num=t.field_number,\n"," field_type=t.type_name(),\n"," buffer=t.format_buffer(),\n"," )\n"," if width > 0 and len(line) > width:\n"," line = line[:width - 1] + '\\u2026' # ellipsis\n"," print(line)\n"," if limit and i + 1 >= limit:\n"," break\n","\n","\n","def main():\n"," import argparse\n"," description = globals()['__doc__'].split('\\n\\n', 1)[0]\n"," p = argparse.ArgumentParser(description=description)\n"," p.add_argument('file', help='file containing raw wire-format proto')\n"," p.add_argument(\n"," '--width',\n"," '-w',\n"," type=int,\n"," default=shutil.get_terminal_size((80, 20)).columns,\n"," help='width of output in columns; <= 0 means unlimited')\n"," p.add_argument(\n"," '--limit',\n"," '-l',\n"," type=int,\n"," help='limit the output to at most this many lines')\n"," args = p.parse_args()\n","\n"," with open(args.file, 'rb') as f:\n"," data = f.read()\n","\n"," decode_and_print(data, width=args.width, limit=args.limit)\n","\n","\n","if __name__ == '__main__':\n"," main()"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Writing proto_parser.py\n"],"name":"stdout"}]},{"cell_type":"markdown","metadata":{"id":"eRrBSjv1Si2Y"},"source":["### Constructing the command"]},{"cell_type":"markdown","metadata":{"id":"07Yj-cZRWL8c"},"source":["First let's inspect the [CBOR](https://en.wikipedia.org/wiki/CBOR) payload with a dummy request. We'll pad it out to roughly the same length as our target payload because we know the length will affect the CBOR encoding."]},{"cell_type":"code","metadata":{"id":"XK2x1ouSSHdi","executionInfo":{"status":"ok","timestamp":1601045411460,"user_tz":240,"elapsed":11225,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"21743fed-46f0-464e-8682-aaaabd230a8f","colab":{"base_uri":"https://localhost:8080/"}},"source":["import json, base64, binascii\n","build_command = 'echo ' + 'x' * len(target_payload)\n","link = Build(build_command, 'CBOR')\n","payload = base64.b64decode(json.loads(link)['payload'])\n","print(binascii.hexlify(payload).decode('utf-8'))"],"execution_count":null,"outputs":[{"output_type":"stream","text":["a567636f6d6d616e6478676563686f207878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878787878696d6174657269616c73a06870726f6475637473a1667374646f7574a1667368613235367840376638343032636439343539303630626665643632373939636334613735396664353863643237333137313734343036643263393635336134616163643832656a627970726f6475637473a0655f74797065646c696e6b\n"],"name":"stdout"}]},{"cell_type":"markdown","metadata":{"id":"sQcHcTR4hn61"},"source":["We can visualize this using :\n","\n","```\n","A5 # map(5)\n"," 67 # text(7)\n"," 636F6D6D616E64 # \"command\"\n"," 78 67 # text(103)\n"," 6563686F20787878... # \"echo xxx...\"\n","...\n","```\n","\n","The field that we have control over, `command`, starts at byte offset 11. Let's see how these first several bytes get interpreted as [protobuf](https://developers.google.com/protocol-buffers/docs/encoding) using our tool above:"]},{"cell_type":"code","metadata":{"id":"ALKtfsSFhSXi","executionInfo":{"status":"ok","timestamp":1601045411461,"user_tz":240,"elapsed":11218,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"9712010d-d995-4507-81a4-d1627012348d","colab":{"base_uri":"https://localhost:8080/"}},"source":["import proto_parser\n","proto_parser.decode_and_print(payload[:19])"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Pos. Fld# Type Value\n","0000 1652 fixed32 ␦g comm\n","0006 12 fixed64 a ndxgecho\n","000F 4 varint ␣ x\n","0011 15 varint x x\n"],"name":"stdout"}]},{"cell_type":"markdown","metadata":{"id":"yVaPtJ07TDLO"},"source":["That means that the victim will interpret our message as having at least two fields, number 1652 and number 12. The real [intoto.proto](https://github.com/grafeas/grafeas/blob/63aff549c1813170558b49e40f41147fd31ad1e3/proto/v1beta1/intoto.proto) has no such fields, which causes the proto library to simply ignore those fields. Lucky for us!\n","\n","Furthermore, we get control over the parsed stream starting at the fifth byte of our command. See the first four bytes (`echo`) are part of field 12 (fixed64) and then the fifth byte (space) is interpreted as a varint-type field number 4?\n","\n","That means we want construct a valid shell command that does nothing but contains our protobuf wire-format payload starting at the fifth byte. We also need to shell-escape our payload so that the command does not fail.\n","\n","Here is such a command:\n","\n","```\n",": ''\n","```\n","\n","Let's try it:"]},{"cell_type":"code","metadata":{"id":"ut3lqnEePpFb","executionInfo":{"status":"ok","timestamp":1601045411463,"user_tz":240,"elapsed":11214,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"5f66ac27-faa1-4959-9d82-4750354051ce","colab":{"base_uri":"https://localhost:8080/"}},"source":["assert b\"'\" not in target_payload\n","build_command = b\": '\" + target_payload + b\"'\"\n","link = Build(build_command, 'CBOR')\n","payload = base64.b64decode(json.loads(link)['payload'])\n","proto_parser.decode_and_print(payload)"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Pos. Fld# Type Value\n","0000 1652 fixed32 ␦g comm\n","0006 12 fixed64 a ndXg:␣␣'\n","000F 1 length=18 ␊ ␒echo␣\"hello␣world\"\n","0023 3 length=76 ␚ L␊␆stdout␒B␊@badbadbadbadbadbadbadbadbadbadbadbadbadba…\n","0071 4 error ' \n"],"name":"stdout"}]},{"cell_type":"markdown","metadata":{"id":"C6hc8NqhoduB"},"source":["Almost there! It correctly interprets fields 1 (`effective_command`) and 3 (`products`), but then it chokes on the `'` character ending our shell command.\n","\n","To fix this, we need to append a tag to our payload to tell the protobuf parser to consume the rest of the input as some dummy field, such as field number 15. The characters `z}` will do precisely that: `z` is field number 15 of type length-delimited, and `~` is length 126, which is the number of remaining bytes.\n","\n","Let's try it out:"]},{"cell_type":"code","metadata":{"id":"OfNQ0btAovqN","executionInfo":{"status":"ok","timestamp":1601045411464,"user_tz":240,"elapsed":11208,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"c8c10817-dd37-4600-b24f-408426468e35","colab":{"base_uri":"https://localhost:8080/"}},"source":["build_command = b\": '\" + target_payload + b\"z~'\"\n","link = Build(build_command, 'CBOR')\n","payload = base64.b64decode(json.loads(link)['payload'])\n","proto_parser.decode_and_print(payload)"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Pos. Fld# Type Value\n","0000 1652 fixed32 ␦g comm\n","0006 12 fixed64 a ndXi:␣␣'\n","000F 1 length=18 ␊ ␒echo␣\"hello␣world\"\n","0023 3 length=76 ␚ L␊␆stdout␒B␊@badbadbadbadbadbadbadbadbadbadbadbadbadba…\n","0071 15 length=126 z ~'imaterials␦hproducts␦fstdout␦fsha256x@e3b0c44298fc1c…\n"],"name":"stdout"}]},{"cell_type":"markdown","metadata":{"id":"BuxJHYvdpOIT"},"source":["Yay! We should be good to go. Let's verify by usiing the real proto parser."]},{"cell_type":"code","metadata":{"id":"eJ2viiK0ppTw","executionInfo":{"status":"ok","timestamp":1601045411465,"user_tz":240,"elapsed":11202,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"4de8f1d8-4663-4576-a439-f9915980b16e","colab":{"base_uri":"https://localhost:8080/"}},"source":["intoto_pb2.Link.FromString(payload)"],"execution_count":null,"outputs":[{"output_type":"execute_result","data":{"text/plain":["effective_command: \"echo \\\"hello world\\\"\"\n","products {\n"," resource_uri: \"stdout\"\n"," hashes {\n"," sha256: \"badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb\"\n"," }\n","}"]},"metadata":{"tags":[]},"execution_count":21}]},{"cell_type":"markdown","metadata":{"id":"52OVcmYOp2SV"},"source":["We're good to go!"]},{"cell_type":"markdown","metadata":{"id":"sw25nmi8pWZr"},"source":["## Steps 3 and 4: Pull off the attack\n","\n","Now that we have constructed our malicious build command, we need to send it to the server and get the victim to consume it."]},{"cell_type":"markdown","metadata":{"id":"YRtmg0NQpZDs"},"source":["First, send the malicious build request to the server and get back a signed CBOR message."]},{"cell_type":"code","metadata":{"id":"MCWY_Bwepfke","executionInfo":{"status":"ok","timestamp":1601045411466,"user_tz":240,"elapsed":11196,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"a54daa72-935e-44e6-a2e7-31c3641611e9","colab":{"base_uri":"https://localhost:8080/"}},"source":["build_command = b\": '\" + target_payload + b\"z~'\"\n","link_original = Build(build_command, 'CBOR')\n","json.loads(link_original) # Print it out for display purposes"],"execution_count":null,"outputs":[{"output_type":"execute_result","data":{"text/plain":["{'payload': 'pWdjb21tYW5kWGk6ICAnChJlY2hvICJoZWxsbyB3b3JsZCIaTAoGc3Rkb3V0EkIKQGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJ6fidpbWF0ZXJpYWxzoGhwcm9kdWN0c6Fmc3Rkb3V0oWZzaGEyNTZ4QGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTVqYnlwcm9kdWN0c6BlX3R5cGVkbGluaw==',\n"," 'payloadType': 'CBOR',\n"," 'signatures': [{'sig': 'LasJ/aXjyKdiVSNrA5uXTiH20D6Am7xa67nBEI9K6ZQYLBitn1NVhMpMEGY6QW7Qnyi6N/LKgZhLsAA5Mvur3Q=='}]}"]},"metadata":{"tags":[]},"execution_count":22}]},{"cell_type":"code","metadata":{"id":"NG7KVgMRvRVa","executionInfo":{"status":"ok","timestamp":1601045411467,"user_tz":240,"elapsed":11190,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"f80e3a37-3d1f-4e6a-ac85-d74e8a1378ce","colab":{"base_uri":"https://localhost:8080/"}},"source":["VerifyAndPrint(link_original)"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Good signature\n","{'_type': 'link',\n"," 'byproducts': {},\n"," 'command': b': \\'\\n\\x12echo \"hello world\"\\x1aL\\n\\x06stdout\\x12B\\n@badbadbadb'\n"," b\"adbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbz~'\",\n"," 'materials': {},\n"," 'products': {'stdout': {'sha256': 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'}}}\n"],"name":"stdout"}]},{"cell_type":"markdown","metadata":{"id":"ct44_2tup9w2"},"source":["Next, change the `payloadType` to `Protobuf`."]},{"cell_type":"code","metadata":{"id":"4VE1vyDuqBJT","executionInfo":{"status":"ok","timestamp":1601045411468,"user_tz":240,"elapsed":11184,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"bd3cb5ea-3a6d-4c4d-90f8-1a3dea65388d","colab":{"base_uri":"https://localhost:8080/"}},"source":["link = json.loads(link_original)\n","link['payloadType'] = 'Protobuf'\n","link_modified = json.dumps(link)\n","json.loads(link_modified) # Print it out for display purposes"],"execution_count":null,"outputs":[{"output_type":"execute_result","data":{"text/plain":["{'payload': 'pWdjb21tYW5kWGk6ICAnChJlY2hvICJoZWxsbyB3b3JsZCIaTAoGc3Rkb3V0EkIKQGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJhZGJ6fidpbWF0ZXJpYWxzoGhwcm9kdWN0c6Fmc3Rkb3V0oWZzaGEyNTZ4QGUzYjBjNDQyOThmYzFjMTQ5YWZiZjRjODk5NmZiOTI0MjdhZTQxZTQ2NDliOTM0Y2E0OTU5OTFiNzg1MmI4NTVqYnlwcm9kdWN0c6BlX3R5cGVkbGluaw==',\n"," 'payloadType': 'Protobuf',\n"," 'signatures': [{'sig': 'LasJ/aXjyKdiVSNrA5uXTiH20D6Am7xa67nBEI9K6ZQYLBitn1NVhMpMEGY6QW7Qnyi6N/LKgZhLsAA5Mvur3Q=='}]}"]},"metadata":{"tags":[]},"execution_count":24}]},{"cell_type":"markdown","metadata":{"id":"6ClrxXmyqv7l"},"source":["Finally, send it to the victim and profit!"]},{"cell_type":"code","metadata":{"id":"mvxELrI0qzHI","executionInfo":{"status":"ok","timestamp":1601045411469,"user_tz":240,"elapsed":11178,"user":{"displayName":"Mark Lodato","photoUrl":"https://lh3.googleusercontent.com/a-/AOh14Gje3VCiWTKvjKn9WNmOK7z5cDhpwtiSncwf3Flh=s64","userId":"14555828759934874531"}},"outputId":"6622bca7-ee77-463b-d6a7-ce7173837a1c","colab":{"base_uri":"https://localhost:8080/"}},"source":["VerifyAndPrint(link_modified)"],"execution_count":null,"outputs":[{"output_type":"stream","text":["Good signature\n","effective_command: \"echo \\\"hello world\\\"\"\n","products {\n"," resource_uri: \"stdout\"\n"," hashes {\n"," sha256: \"badbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadbadb\"\n"," }\n","}\n","\n"],"name":"stdout"}]}]} -------------------------------------------------------------------------------- /implementation/README.md: -------------------------------------------------------------------------------- 1 | # Signing-spec reference implementation 2 | 3 | The Python module `signing_spec.py` contains a reference implementation. A test 4 | vector is contained as a docstring at the top of the file. 5 | -------------------------------------------------------------------------------- /implementation/ecdsa.py: -------------------------------------------------------------------------------- 1 | """Example crypto implementation: ECDSA with deterministic-rfc6979 and SHA256. 2 | 3 | Copyright 2021 Google LLC. 4 | SPDX-License-Identifier: Apache-2.0 5 | """ 6 | 7 | from Crypto.Hash import SHA256 8 | from Crypto.PublicKey import ECC 9 | from Crypto.Signature import DSS 10 | 11 | 12 | class Signer: 13 | def __init__(self, secret_key): 14 | self.secret_key = secret_key 15 | self.public_key = self.secret_key.public_key() 16 | 17 | @classmethod 18 | def construct(cls, *, curve, d, point_x, point_y): 19 | return cls( 20 | ECC.construct(curve=curve, d=d, point_x=point_x, point_y=point_y)) 21 | 22 | @classmethod 23 | def generate(cls, *, curve, randfunc=None): 24 | return cls(ECC.generate(curve=curve, randfunc=randfunc)) 25 | 26 | def sign(self, message: bytes) -> bytes: 27 | """Returns the signature of `message`.""" 28 | h = SHA256.new(message) 29 | return DSS.new(self.secret_key, 'deterministic-rfc6979').sign(h) 30 | 31 | def keyid(self) -> str: 32 | """Returns a fingerprint of the public key.""" 33 | return Verifier(self.public_key).keyid() 34 | 35 | 36 | class Verifier: 37 | def __init__(self, public_key): 38 | self.public_key = public_key 39 | 40 | def verify(self, message: bytes, signature: bytes) -> bool: 41 | """Returns true if `message` was signed by `signature`.""" 42 | h = SHA256.new(message) 43 | try: 44 | DSS.new(self.public_key, 'fips-186-3').verify(h, signature) 45 | return True 46 | except ValueError: 47 | return False 48 | 49 | def keyid(self) -> str: 50 | """Returns a fingerprint of the public key.""" 51 | # Note: This is a hack for demonstration purposes. A proper fingerprint 52 | # should be used. 53 | key = self.public_key.export_key(format='OpenSSH').encode('ascii') 54 | return SHA256.new(key).hexdigest()[:8] 55 | -------------------------------------------------------------------------------- /implementation/signing_spec.py: -------------------------------------------------------------------------------- 1 | r"""Reference implementation of signing-spec. 2 | 3 | Copyright 2021 Google LLC. 4 | SPDX-License-Identifier: Apache-2.0 5 | 6 | The following example requires `pip3 install pycryptodome` and uses ecdsa.py in 7 | the same directory as this file. 8 | 9 | >>> import os, sys 10 | >>> from pprint import pprint 11 | >>> sys.path.insert(0, os.path.dirname(__file__)) 12 | >>> import ecdsa 13 | 14 | >>> signer = ecdsa.Signer.construct( 15 | ... curve='P-256', 16 | ... d=97358161215184420915383655311931858321456579547487070936769975997791359926199, 17 | ... point_x=46950820868899156662930047687818585632848591499744589407958293238635476079160, 18 | ... point_y=5640078356564379163099075877009565129882514886557779369047442380624545832820) 19 | >>> verifier = ecdsa.Verifier(signer.public_key) 20 | >>> payloadType = 'http://example.com/HelloWorld' 21 | >>> payload = b'hello world' 22 | 23 | Signing example: 24 | 25 | >>> signature_json = Sign(payloadType, payload, signer) 26 | >>> pprint(json.loads(signature_json)) 27 | {'payload': 'aGVsbG8gd29ybGQ=', 28 | 'payloadType': 'http://example.com/HelloWorld', 29 | 'signatures': [{'keyid': '66301bbf', 30 | 'sig': 'A3JqsQGtVsJ2O2xqrI5IcnXip5GToJ3F+FnZ+O88SjtR6rDAajabZKciJTfUiHqJPcIAriEGAHTVeCUjW2JIZA=='}]} 31 | 32 | Verification example: 33 | 34 | >>> result = Verify(signature_json, [('mykey', verifier)]) 35 | >>> pprint(result) 36 | VerifiedPayload(payloadType='http://example.com/HelloWorld', payload=b'hello world', recognizedSigners=['mykey']) 37 | 38 | PAE: 39 | 40 | >>> PAE(payloadType, payload) 41 | b'DSSEv1 29 http://example.com/HelloWorld 11 hello world' 42 | """ 43 | 44 | import base64, binascii, dataclasses, json, struct 45 | 46 | # Protocol requires Python 3.8+. 47 | from typing import Iterable, List, Optional, Protocol, Tuple 48 | 49 | 50 | class Signer(Protocol): 51 | def sign(self, message: bytes) -> bytes: 52 | """Returns the signature of `message`.""" 53 | ... 54 | 55 | def keyid(self) -> Optional[str]: 56 | """Returns the ID of this key, or None if not supported.""" 57 | ... 58 | 59 | 60 | class Verifier(Protocol): 61 | def verify(self, message: bytes, signature: bytes) -> bool: 62 | """Returns true if `message` was signed by `signature`.""" 63 | ... 64 | 65 | def keyid(self) -> Optional[str]: 66 | """Returns the ID of this key, or None if not supported.""" 67 | ... 68 | 69 | 70 | # Collection of verifiers, each of which is associated with a name. 71 | VerifierList = Iterable[Tuple[str, Verifier]] 72 | 73 | 74 | @dataclasses.dataclass 75 | class VerifiedPayload: 76 | payloadType: str 77 | payload: bytes 78 | recognizedSigners: List[str] # List of names of signers 79 | 80 | 81 | def b64enc(m: bytes) -> str: 82 | return base64.standard_b64encode(m).decode('utf-8') 83 | 84 | 85 | def b64dec(m: str) -> bytes: 86 | m = m.encode('utf-8') 87 | try: 88 | return base64.b64decode(m, validate=True) 89 | except binascii.Error: 90 | return base64.b64decode(m, altchars='-_', validate=True) 91 | 92 | 93 | def PAE(payloadType: str, payload: bytes) -> bytes: 94 | return b'DSSEv1 %d %b %d %b' % ( 95 | len(payloadType), payloadType.encode('utf-8'), 96 | len(payload), payload) 97 | 98 | 99 | def Sign(payloadType: str, payload: bytes, signer: Signer) -> str: 100 | signature = { 101 | 'keyid': signer.keyid(), 102 | 'sig': b64enc(signer.sign(PAE(payloadType, payload))), 103 | } 104 | if not signature['keyid']: 105 | del signature['keyid'] 106 | return json.dumps({ 107 | 'payload': b64enc(payload), 108 | 'payloadType': payloadType, 109 | 'signatures': [signature], 110 | }) 111 | 112 | 113 | def Verify(json_signature: str, verifiers: VerifierList) -> VerifiedPayload: 114 | wrapper = json.loads(json_signature) 115 | payloadType = wrapper['payloadType'] 116 | payload = b64dec(wrapper['payload']) 117 | pae = PAE(payloadType, payload) 118 | recognizedSigners = [] 119 | for signature in wrapper['signatures']: 120 | for name, verifier in verifiers: 121 | if (signature.get('keyid') is not None and 122 | verifier.keyid() is not None and 123 | signature.get('keyid') != verifier.keyid()): 124 | continue 125 | if verifier.verify(pae, b64dec(signature['sig'])): 126 | recognizedSigners.append(name) 127 | if not recognizedSigners: 128 | raise ValueError('No valid signature found') 129 | return VerifiedPayload(payloadType, payload, recognizedSigners) 130 | 131 | 132 | if __name__ == '__main__': 133 | import doctest 134 | doctest.testmod() 135 | -------------------------------------------------------------------------------- /protocol.md: -------------------------------------------------------------------------------- 1 | # DSSE Protocol 2 | 3 | May 10, 2024 4 | 5 | Version 1.0.2 6 | 7 | This document describes the protocol/algorithm for creating and verifying DSSE 8 | signatures, independent of how they are transmitted or stored. For the 9 | recommended data structure, see [Envelope](envelope.md). 10 | 11 | ## Signature Definition 12 | 13 | A signature is defined as: 14 | 15 | ```none 16 | SIGNATURE = Sign(PAE(UTF8(PAYLOAD_TYPE), SERIALIZED_BODY)) 17 | ``` 18 | 19 | Parameters: 20 | 21 | Name | Type | Required | Authenticated 22 | --------------- | ------ | -------- | ------------- 23 | SERIALIZED_BODY | bytes | Yes | Yes 24 | PAYLOAD_TYPE | string | Yes | Yes 25 | KEYID | string | No | No 26 | 27 | * SERIALIZED_BODY: Arbitrary byte sequence to be signed. 28 | 29 | * PAYLOAD_TYPE: Opaque, case-sensitive string that uniquely and unambiguously 30 | identifies how to interpret `payload`. This includes both the encoding 31 | (JSON, CBOR, etc.) as well as the meaning/schema. To prevent collisions, the 32 | value SHOULD be either: 33 | 34 | * [Media Type](https://www.iana.org/assignments/media-types/), a.k.a. MIME 35 | type or Content Type 36 | * Example: `application/vnd.in-toto+json`. 37 | * IMPORTANT: This SHOULD be an application-specific type describing 38 | both encoding and schema, NOT a generic type like 39 | `application/json`. The problem with generic types is that two 40 | different applications could use the same encoding (e.g. JSON) but 41 | interpret the payload differently. 42 | * SHOULD be lowercase. 43 | * [URI](https://tools.ietf.org/html/rfc3986) 44 | * Example: `https://example.com/MyMessage/v1-json`. 45 | * SHOULD resolve to a human-readable description but MAY be 46 | unresolvable. 47 | * SHOULD be case-normalized (section 6.2.2.1) 48 | 49 | * KEYID: Optional, unauthenticated hint indicating what key and algorithm was 50 | used to sign the message. As with Sign(), details are agreed upon 51 | out-of-band by the signer and verifier. It **MUST NOT** be used for security 52 | decisions; it may only be used to narrow the selection of possible keys to 53 | try. 54 | 55 | Functions: 56 | 57 | * PAE() is the "Pre-Authentication Encoding", where parameters `type` and 58 | `body` are byte sequences: 59 | 60 | ```none 61 | PAE(type, body) = "DSSEv1" + SP + LEN(type) + SP + type + SP + LEN(body) + SP + body 62 | + = concatenation 63 | SP = ASCII space [0x20] 64 | "DSSEv1" = ASCII [0x44, 0x53, 0x53, 0x45, 0x76, 0x31] 65 | LEN(s) = ASCII decimal encoding of the byte length of s, with no leading zeros 66 | ``` 67 | 68 | * Sign() is an arbitrary digital signature format. Details are agreed upon 69 | out-of-band by the signer and verifier. This specification places no 70 | restriction on the signature algorithm or format. 71 | 72 | * UTF8() is [UTF-8 encoding](https://tools.ietf.org/html/rfc3629), 73 | transforming a unicode string to a byte sequence. 74 | 75 | ## Protocol 76 | 77 | Out of band: 78 | 79 | - Agree on a PAYLOAD_TYPE and cryptographic details, optionally including 80 | KEYID. 81 | 82 | To sign: 83 | 84 | - Serialize the message according to PAYLOAD_TYPE. Call the result 85 | SERIALIZED_BODY. 86 | - Sign PAE(UTF8(PAYLOAD_TYPE), SERIALIZED_BODY). Call the result SIGNATURE. 87 | - Optionally, compute a KEYID. 88 | - Encode and transmit SERIALIZED_BODY, PAYLOAD_TYPE, SIGNATURE, and KEYID, 89 | preferably using the recommended [JSON envelope](envelope.md). 90 | 91 | To verify: 92 | 93 | - Receive and decode SERIALIZED_BODY, PAYLOAD_TYPE, SIGNATURE, and KEYID, such 94 | as from the recommended [JSON envelope](envelope.md). Reject if decoding 95 | fails. 96 | - Optionally, filter acceptable public keys by KEYID. 97 | - Verify SIGNATURE against PAE(UTF8(PAYLOAD_TYPE), SERIALIZED_BODY). Reject if 98 | the verification fails. 99 | - Reject if PAYLOAD_TYPE is not a supported type. 100 | - Parse SERIALIZED_BODY according to PAYLOAD_TYPE. Reject if the parsing 101 | fails. 102 | 103 | Either standard or URL-safe base64 encodings are allowed. Signers may use 104 | either, and verifiers **MUST** accept either. 105 | 106 | **Important:** Implementations MUST ensure that the same SERIALIZED_BODY that is 107 | verified is the same sent to the application layer. In particular, 108 | implementations MUST NOT re-parse the envelope after verification to pull out 109 | the payload. Failure to adhere to this requirement can lead to security 110 | vulnerabilities. 111 | 112 | ## Multi-signature Verification 113 | 114 | Multi-signature enhances the security by allowing multiple signers to sign the 115 | same payload. The resulting signatures are encoded and transmitted, preferably 116 | using the recommended [JSON envelope](envelope.md). 117 | 118 | A `(t, n)`-ENVELOPE is valid if the enclosed signatures pass the verification 119 | against at least `t` of `n` unique trusted public keys where `t` is 120 | application-specific. 121 | 122 | To verify a `(t, n)`-ENVELOPE: 123 | 124 | - Receive and decode SERIALIZED_BODY, PAYLOAD_TYPE, SIGNATURES from ENVELOPE. 125 | Reject if decoding fails. 126 | - For each (SIGNATURE, KEYID) in SIGNATURES, 127 | - Optionally, filter acceptable public keys by KEYID. 128 | - Verify SIGNATURE against PAE(UTF8(PAYLOAD_TYPE), SERIALIZED_BODY). Skip 129 | over if the verification fails. 130 | - Add the accepted public key to the set ACCEPTED_KEYS. 131 | - Break if the number of unique keys in ACCEPTED_KEYS is greater or equal 132 | to `t`. 133 | - Reject if the unique keys in ACCEPTED_KEYS is less than `t`. 134 | - Reject if PAYLOAD_TYPE is not a supported type. 135 | - Parse SERIALIZED_BODY according to PAYLOAD_TYPE. Reject if the parsing 136 | fails. 137 | 138 | ## Test Vectors 139 | 140 | See [reference implementation](implementation/signing_spec.py). Here is an 141 | example. 142 | 143 | SERIALIZED_BODY: 144 | 145 | ```none 146 | hello world 147 | ``` 148 | 149 | PAYLOAD_TYPE: 150 | 151 | ```none 152 | http://example.com/HelloWorld 153 | ``` 154 | 155 | PAE: 156 | 157 | ```none 158 | DSSEv1 29 http://example.com/HelloWorld 11 hello world 159 | ``` 160 | 161 | Cryptographic keys: 162 | 163 | ```none 164 | Algorithm: ECDSA over NIST P-256 and SHA-256, with deterministic-rfc6979 165 | Signature: raw concatenation of r and s (Cryptodome binary encoding) 166 | X: 46950820868899156662930047687818585632848591499744589407958293238635476079160 167 | Y: 5640078356564379163099075877009565129882514886557779369047442380624545832820 168 | d: 97358161215184420915383655311931858321456579547487070936769975997791359926199 169 | ``` 170 | 171 | Result (using the recommended [JSON envelope](envelope.md)): 172 | 173 | ```json 174 | {"payload": "aGVsbG8gd29ybGQ=", 175 | "payloadType": "http://example.com/HelloWorld", 176 | "signatures": [{"sig": "A3JqsQGtVsJ2O2xqrI5IcnXip5GToJ3F+FnZ+O88SjtR6rDAajabZKciJTfUiHqJPcIAriEGAHTVeCUjW2JIZA=="}]} 177 | ``` 178 | 179 | [Canonical JSON]: http://wiki.laptop.org/go/Canonical_JSON 180 | [in-toto]: https://in-toto.io 181 | [JWS]: https://tools.ietf.org/html/rfc7515 182 | [PASETO]: https://github.com/paragonie/paseto/blob/master/docs/01-Protocol-Versions/Version2.md#sig 183 | [TUF]: https://theupdateframework.io 184 | --------------------------------------------------------------------------------