From 710ac53630474d4a58f0cb42a808500a791a7b29 Mon Sep 17 00:00:00 2001 From: Brian DeHamer Date: Thu, 29 Jun 2023 17:41:59 -0700 Subject: [PATCH] refactor merkle tree inclusion proof verification Signed-off-by: Brian DeHamer --- .changeset/happy-panthers-dance.md | 2 + .../src/__tests__/merkle/digest.test.ts | 56 ----- .../src/__tests__/merkle/verify.test.ts | 94 --------- .../src/__tests__/tlog/verify/merkle.test.ts | 197 ++++++++++++++++++ packages/client/src/merkle/digest.ts | 49 ----- packages/client/src/merkle/index.ts | 18 -- packages/client/src/merkle/verify.ts | 110 ---------- packages/client/src/tlog/verify/merkle.ts | 128 ++++++++++++ 8 files changed, 327 insertions(+), 327 deletions(-) create mode 100644 .changeset/happy-panthers-dance.md delete mode 100644 packages/client/src/__tests__/merkle/digest.test.ts delete mode 100644 packages/client/src/__tests__/merkle/verify.test.ts create mode 100644 packages/client/src/__tests__/tlog/verify/merkle.test.ts delete mode 100644 packages/client/src/merkle/digest.ts delete mode 100644 packages/client/src/merkle/index.ts delete mode 100644 packages/client/src/merkle/verify.ts create mode 100644 packages/client/src/tlog/verify/merkle.ts diff --git a/.changeset/happy-panthers-dance.md b/.changeset/happy-panthers-dance.md new file mode 100644 index 00000000..a845151c --- /dev/null +++ b/.changeset/happy-panthers-dance.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/client/src/__tests__/merkle/digest.test.ts b/packages/client/src/__tests__/merkle/digest.test.ts deleted file mode 100644 index 386ccf77..00000000 --- a/packages/client/src/__tests__/merkle/digest.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -/* -Copyright 2022 GitHub, Inc - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Hasher } from '../../merkle/digest'; - -describe('Hasher', () => { - it('should create an instance', () => { - const hasher = new Hasher(); - expect(hasher).toBeTruthy(); - }); - - describe('when the hash algorithm is sha256', () => { - const subject = new Hasher('sha256'); - - describe('size', () => { - it('is 32', () => { - expect(subject.size()).toBe(32); - }); - }); - - describe('hashLeaf', () => { - it('hashes a leaf', () => { - const hash = subject.hashLeaf(Buffer.from('hello')); - - expect(hash).toEqual( - Buffer.from('iipcm3aIJ95alVLDigRMZpWcaPbS8htSYK9U0vh9uCc=', 'base64') - ); - }); - }); - - describe('hashChildren', () => { - it('hashes the children', () => { - const l = Buffer.from('left'); - const r = Buffer.from('right'); - const hash = subject.hashChildren(l, r); - - expect(hash).toEqual( - Buffer.from('I8JKzKnpqpZvObkOQVTZEzNPoWDjX8vNA05nO/szOa0=', 'base64') - ); - }); - }); - }); -}); diff --git a/packages/client/src/__tests__/merkle/verify.test.ts b/packages/client/src/__tests__/merkle/verify.test.ts deleted file mode 100644 index 773a6dff..00000000 --- a/packages/client/src/__tests__/merkle/verify.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/* -Copyright 2022 GitHub, Inc - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { Hasher } from '../../merkle/digest'; -import { verifyInclusion } from '../../merkle/verify'; - -describe('verifyInclusion', () => { - const defaultHasher = new Hasher(); - - // Test data comes from https://rekor.sigstore.dev/api/v1/log/entries?logIndex=3056587 - const entry = - 'eyJhcGlWZXJzaW9uIjoiMC4wLjEiLCJraW5kIjoicmVrb3JkIiwic3BlYyI6eyJkYXRhIjp7Imhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI0OGQ5OTkyNjBkZmY4ZDgzNGY4MWM4NWE3NGY1MDk1YWY4ZWRlNTM5ZWEzYWVhMTk5NTc4N2M0NTU1YzZiZTQ3In19LCJzaWduYXR1cmUiOnsiY29udGVudCI6Ik1FVUNJUURDRnBjaHVyQTNTWVNFMTJHTG9qUjRZRmR5RFpkaU9IbkppYVdLRENiVW53SWdHSnJRUm5qcm9xUmtuRiszRUdqaHhqaURUZDhURXB2MTVmZkRBR2Z6enVVPSIsImZvcm1hdCI6Ing1MDkiLCJwdWJsaWNLZXkiOnsiY29udGVudCI6IkxTMHRMUzFDUlVkSlRpQlFWVUpNU1VNZ1MwVlpMUzB0TFMwS1RVWnJkMFYzV1VoTGIxcEplbW93UTBGUldVbExiMXBKZW1vd1JFRlJZMFJSWjBGRlRXVmpZWFVyV1daTlJsZFpjazV0ZDFreldVaE5kM0E0VlUwMU1RcHhaV1o1ZG1oWmNqSnhaV0pLV0ZCdFdFY3hWM0JpYkdwWFkyYzVaekZHVDJoUWFXVjVPVWh6VEVsdGFERnJWMlJqYkhjdlkzcERWMUYzUFQwS0xTMHRMUzFGVGtRZ1VGVkNURWxESUV0RldTMHRMUzB0Q2c9PSJ9fX19'; - const entryBytes = Buffer.from(entry, 'base64'); - - const hashes = [ - '6f6ad87e72762b653c96db394eadcb58a8bbce16ba72fd3d1ad56421baefd0af', - '2fa68c2f97cdf8257b0e8103d10ab6320c5007099c8f9e2c2196db634dc364f5', - '9f8f947be87b4f343a0ad1a4835cabd0c744eff2a51b852057e8459e2bb55a56', - '336e1fc366c19dd34250e10baea5ef74caa0a5b7db0ae92047fcda8067c0385f', - '5da501f1e7f45fe63af0b09373b4f5578b3899d3fc6e0ec7262387d2975eb22c', - '490b00a963228e7a8f70762d4856eeeb8280413e1d1cb7fb4e9683e223b5f6ba', - '075e588755235f14fddd7190ac9347b6ec045ce4c88a0794ce193cbfe3d60142', - '167fe9d15fd6a3de12ae2747785e23c9425bf677c7fd957976cae3e27b6d9bd1', - 'eee183886501e85197c62c6c0d371e479115f71a50618d8552b73b228717a5ae', - 'ef118b4a9e69d9000c48515d7e2f30ce606197d21e6bacc82fd7a61c48a67507', - 'bd0cbc5a454654ff88d2e08e3c443204308e423834e0fed2481b847da5d2dfcf', - 'cec90b763ab869bc411f51ed8f4206b4d051c9502533f2638b15277fd2ae5e9e', - '4e2d269ec9ef9d4e9270c482b70a64e75d84f850902b5e5e65acd14844cf659b', - 'd63d28e2b4a9476ea2df69f03be3e9dd453c2232ed172e5f79487a9e60711f51', - '599040ed55824445153996171c1045d696176b6a252159aaa3630c033b45e70c', - '860efc785c66773fe35db221860cfb482dfe0a6bfe602aaade78149084c9ab86', - '39da4af7f0fbe095e6bc78c146ad59c70881d3fcb22e90c7675dee5e6736bc76', - '806e7153f439cdb4558d7ac89defb0e17aa9742e888cb660774b94b5399c3322', - 'efb36cfc54705d8cd921a621a9389ffa03956b15d68bfabadac2b4853852079b', - ]; - const proof = hashes.map((hash) => Buffer.from(hash, 'hex')); - - const rootHash = - '8326106075b643a01711dddf32e9b361b475ca21e9d81afec867a3c6a3dbb55c'; - const root = Buffer.from(rootHash, 'hex'); - - const index = BigInt(3056587); - const size = BigInt(3063448); - - describe('when everything matches perfectly', () => { - it('returns true', () => { - const leafHash = defaultHasher.hashLeaf(entryBytes); - - expect( - verifyInclusion(defaultHasher, index, size, leafHash, proof, root) - ).toBe(true); - }); - }); - - describe('when the leaf hash is incorrect', () => { - it('returns false', () => { - const leafHash = defaultHasher.hashLeaf(Buffer.from('wrong')); - - expect( - verifyInclusion(defaultHasher, index, size, leafHash, proof, root) - ).toBe(false); - }); - }); - - describe('when the proof is missing hashes', () => { - it('returns false', () => { - const leafHash = defaultHasher.hashLeaf(entryBytes); - - expect(() => { - verifyInclusion( - defaultHasher, - index, - size, - leafHash, - proof.slice(2), - root - ); - }).toThrow('invalid proof length'); - }); - }); -}); diff --git a/packages/client/src/__tests__/tlog/verify/merkle.test.ts b/packages/client/src/__tests__/tlog/verify/merkle.test.ts new file mode 100644 index 00000000..609910ec --- /dev/null +++ b/packages/client/src/__tests__/tlog/verify/merkle.test.ts @@ -0,0 +1,197 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { VerificationError } from '../../../error'; +import { verifyMerkleInclusion } from '../../../tlog/verify/merkle'; +import * as sigstore from '../../../types/sigstore'; + +describe('verifyMerkleInclusion', () => { + // Test data comes from https://rekor.sigstore.dev/api/v1/log/entries?logIndex=25591465 + const canonicalizedBody = Buffer.from( + 'eyJhcGlWZXJzaW9uIjoiMC4wLjIiLCJraW5kIjoiaW50b3RvIiwic3BlYyI6eyJjb250ZW50Ijp7ImVudmVsb3BlIjp7InBheWxvYWRUeXBlIjoiYXBwbGljYXRpb24vdm5kLmluLXRvdG8ranNvbiIsInNpZ25hdHVyZXMiOlt7InB1YmxpY0tleSI6IkxTMHRMUzFDUlVkSlRpQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENrMUpTVU13VkVORFFXeGhaMEYzU1VKQlowbFZRMGxaYmpsclZWQkRTV2hqTVdKcFJFTTBhMlJQV1ROVGNXRm5kME5uV1VsTGIxcEplbW93UlVGM1RYY0tUbnBGVmsxQ1RVZEJNVlZGUTJoTlRXTXliRzVqTTFKMlkyMVZkVnBIVmpKTlVqUjNTRUZaUkZaUlVVUkZlRlo2WVZka2VtUkhPWGxhVXpGd1ltNVNiQXBqYlRGc1drZHNhR1JIVlhkSWFHTk9UV3BOZDA1cVNUVk5WR040VFVSVk1WZG9ZMDVOYWsxM1RtcEpOVTFVWTNsTlJGVXhWMnBCUVUxR2EzZEZkMWxJQ2t0dldrbDZhakJEUVZGWlNVdHZXa2w2YWpCRVFWRmpSRkZuUVVVelZVOVVZWGhKVW5sUFdHbHJOM1JCVFRKT01rcFNkV04zVG1aRmNYUXdRbkE1Tm1FS2VHd3hORUpOV0ZRMWR5OW1lVzAwWmtNd1JFUnZUazVyY0ZaWVVtZHFhMjkxTTBjeFpsa3dZV3RzY2xJclJpOWFWWEZQUTBGWVZYZG5aMFo0VFVFMFJ3cEJNVlZrUkhkRlFpOTNVVVZCZDBsSVowUkJWRUpuVGxaSVUxVkZSRVJCUzBKblozSkNaMFZHUWxGalJFRjZRV1JDWjA1V1NGRTBSVVpuVVZWWlRXbGxDa2xRVVhKWUwwdE5NM2xqTWtZeFNHRnhXSEZwTTJNMGQwaDNXVVJXVWpCcVFrSm5kMFp2UVZVek9WQndlakZaYTBWYVlqVnhUbXB3UzBaWGFYaHBORmtLV2tRNGQwaDNXVVJXVWpCU1FWRklMMEpDVlhkRk5FVlNXVzVLY0ZsWE5VRmFSMVp2V1ZjeGJHTnBOV3BpTWpCM1RFRlpTMHQzV1VKQ1FVZEVkbnBCUWdwQlVWRmxZVWhTTUdOSVRUWk1lVGx1WVZoU2IyUlhTWFZaTWpsMFRESjRkbG95YkhWTU1qbG9aRmhTYjAxRE5FZERhWE5IUVZGUlFtYzNPSGRCVVdkRkNrbEJkMlZoU0ZJd1kwaE5Oa3g1T1c1aFdGSnZaRmRKZFZreU9YUk1NbmgyV2pKc2RVd3lPV2hrV0ZKdlRVbEhTMEpuYjNKQ1owVkZRV1JhTlVGblVVTUtRa2gzUldWblFqUkJTRmxCTTFRd2QyRnpZa2hGVkVwcVIxSTBZMjFYWXpOQmNVcExXSEpxWlZCTE15OW9OSEI1WjBNNGNEZHZORUZCUVVkS1EwTk1lUXAxVVVGQlFrRk5RVko2UWtaQmFVRnphbTh6YTBvd1lYWlFkelUwY2tGVVNHNVNRelF6TkZKUVpFMHZlbTlCVldsdFRuZERSQ3MyZVZsUlNXaEJURXQ0Q2pGdWVsUk9NSGxEYTBKdVRUbFlTMVkyUVdSRGVVdFdkMmh2TjFKeU1GbEdORW95WldGclMweE5RVzlIUTBOeFIxTk5ORGxDUVUxRVFUSnJRVTFIV1VNS1RWRkVORzgwWVRoa2VraDNZbFIwTmpJMU5FazFXWHBETVVoWVJVSnRlazQwVkVoYVpGQnBMMlpZZFc1T2NXTTVMMjF2V0d0cWQxcExTSE5DWWtSV09RcElOVmxEVFZGRVRVRTNaemhQWldwSU5rMTNVa2xpVXk5WFVUTjFOM1ZWV21WYVNsRnFTMFJHWWpkTmJHTmxRbFI0SzBRdlQzTnFkVkpOTldwWWRtNWFDa1l5UmxaeVYxVTlDaTB0TFMwdFJVNUVJRU5GVWxSSlJrbERRVlJGTFMwdExTMD0iLCJzaWciOiJUVVZWUTBsUlJFNXBWWGxPZWxwc05pOU1iblJ4ZUdoNVRsZExUbkZJYm1aRFFrWnNWRWxCYjNKdFNtSnFXRWRqZVZGSlowOVFSM0ZNYm5wWFlWWlJWbk5KUlZwQ1MyNXJURzlWVGtJMlJIWkRVVGRxT0RaTVJIZ3dObE13ZEZrOSJ9XX0sImhhc2giOnsiYWxnb3JpdGhtIjoic2hhMjU2IiwidmFsdWUiOiI3OWJiMDRhN2VhMDA3M2FiN2VmY2NkYjlkOWM5NTQ0MWJiOWJkNjQ5ZGE5ZTc0YzNlNzkzMTVlZTk5NjFkYWE4In0sInBheWxvYWRIYXNoIjp7ImFsZ29yaXRobSI6InNoYTI1NiIsInZhbHVlIjoiOGE3NWY2YzhkYzRkOWY0MDcyODViYjQ5ZDNlMDE5YTVlNjY2ZjQzNDk5YzI3MDdkOTI4OWFhMjdjM2MyYTY3ZSJ9fX19', + 'base64' + ); + const inclusionProof = { + logIndex: '21428034', + treeSize: '21476367', + hashes: [ + Buffer.from( + '19d3dbf73de6aefabc91f0b0e143b98aa85a09da0fe425ec5f1cd6d156f71618', + 'hex' + ), + Buffer.from( + 'd1142137fddf94069fea54345b912823d6b58b6c11988056581e14328cc1030f', + 'hex' + ), + Buffer.from( + '93dc3b1aa26f0abe487597beae4e195e906e749e8e4ddeee27e79dbde5b91402', + 'hex' + ), + Buffer.from( + '39f0b7d480d19cf281a3fd1bb9e22d2e00c4439872b4bbf93356761ff7924d01', + 'hex' + ), + Buffer.from( + 'c4dbc3de6933e3adec550dd14c7fee8959ae510524e8028c3e755c3c6b865be4', + 'hex' + ), + Buffer.from( + 'fb0c3969556c47a5da58cb9cd58de783f1d133f145b2a24ff6c7767ad0557b20', + 'hex' + ), + Buffer.from( + '8a8f4d600381615d5a9ebc191241cffa65299e80a5f48562f63d72630aedf0c6', + 'hex' + ), + Buffer.from( + '6e3cfb0b5ac7d32e7e58f51324748e12600d03fe293049fe36247fed3fb2fa65', + 'hex' + ), + Buffer.from( + 'ea20898f9069a9d85faf515f20b062b56b1ff4c1750ce4d741acead08b254b4a', + 'hex' + ), + Buffer.from( + '3c0c2711b5709e116362413734eebf10b2dcd81cadd2325502254585f5408a93', + 'hex' + ), + Buffer.from( + '293213bbbac895205eb11b6a4f905eeb2632182aa8022fc96c109d5fa9d9ea31', + 'hex' + ), + Buffer.from( + 'f6f08053bc2277b800e3bfbc74db76a24015f1f38a17e438fac9f3e3a49aa1d4', + 'hex' + ), + Buffer.from( + '0be5c7bbcf481d1efcfc63a27fce447cf6345f7bb3155cf5de39de592c16d52d', + 'hex' + ), + Buffer.from( + 'f597f4bae8df3d6fc6eebfe3eabd7d393e08781f6f16b42398eca8512398fff1', + 'hex' + ), + Buffer.from( + '4e35fcb3c0a59e7f329994002b38db35f5d511f499ba009e10b31f5d27563607', + 'hex' + ), + Buffer.from( + '47044b7ac3aab820e44f0010538d7de71e17a11f4140cbbe9eeb37f78b77cc7d', + 'hex' + ), + Buffer.from( + 'a096e8b56b363e063fb47944b05535e10247eae804325cc5c5df3d024b61e9bf', + 'hex' + ), + Buffer.from( + 'ff41aa21106dbe03996b4335dd158c7ffafd144e45022193de19b2b9136c3e42', + 'hex' + ), + Buffer.from( + 'e6ebdeef2e23335d8d7049ba5a0049a90593efdfe9c1b4548946b44a19d7214f', + 'hex' + ), + Buffer.from( + 'dd51e840e892d70093ad7e1db1e2dea3d50334c7345d360e444d22fc49ed9f5e', + 'hex' + ), + Buffer.from( + 'ad712c98424de0f1284d4f144b8a95b5d22c181d4c0a246518e7a9a220bdf643', + 'hex' + ), + ], + rootHash: Buffer.from( + 'd5c395a44b9537f8fa2524a6e93071969dd475ac40e87e2b1231b2aebd9a138b', + 'hex' + ), + }; + + describe('when the inclusion proof is valid', () => { + const entry = { + canonicalizedBody, + inclusionProof, + } as sigstore.TransparencyLogEntry; + + it('returns true', () => { + expect(verifyMerkleInclusion(entry)).toBe(true); + }); + }); + + describe('when the entry does NOT match the inclusion proof', () => { + const invalidEntry = { + canonicalizedBody: Buffer.from('invalid'), + inclusionProof, + } as sigstore.TransparencyLogEntry; + + it('returns false', () => { + expect(verifyMerkleInclusion(invalidEntry)).toBe(false); + }); + }); + + describe('when the log index is invalid', () => { + const invalidEntry = { + canonicalizedBody, + inclusionProof: { ...inclusionProof, logIndex: '-1' }, + } as sigstore.TransparencyLogEntry; + + it('throws an error', () => { + expect(() => verifyMerkleInclusion(invalidEntry)).toThrow( + VerificationError + ); + }); + }); + + describe('when the entry does NOT contain an inclusion proof', () => { + const invalidEntry = { + canonicalizedBody, + inclusionProof: undefined, + } as sigstore.TransparencyLogEntry; + + it('throws an error', () => { + expect(() => verifyMerkleInclusion(invalidEntry)).toThrow( + VerificationError + ); + }); + }); + + describe('when the inclusion proof log index is greather than the tree size', () => { + const invalidEntry = { + canonicalizedBody, + inclusionProof: { ...inclusionProof, treeSize: '99', logIndex: '100' }, + } as sigstore.TransparencyLogEntry; + + it('throws an error true', () => { + expect(() => verifyMerkleInclusion(invalidEntry)).toThrow( + VerificationError + ); + }); + }); + + describe('when the inclusion proof is missing hashes', () => { + const invalidEntry = { + canonicalizedBody: Buffer.from('foo'), + inclusionProof: { + ...inclusionProof, + hashes: inclusionProof.hashes.slice(0, 1), + }, + } as sigstore.TransparencyLogEntry; + + it('throws an error true', () => { + expect(() => verifyMerkleInclusion(invalidEntry)).toThrow( + VerificationError + ); + }); + }); +}); diff --git a/packages/client/src/merkle/digest.ts b/packages/client/src/merkle/digest.ts deleted file mode 100644 index 5efc1409..00000000 --- a/packages/client/src/merkle/digest.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* -Copyright 2022 GitHub, Inc - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import crypto from 'crypto'; - -const RFC6962LeafHashPrefix = Buffer.from([0x00]); -const RFC6962NodeHashPrefix = Buffer.from([0x01]); - -// Implements Merkle Tree Hash logic according to RFC6962. -// https://datatracker.ietf.org/doc/html/rfc6962#section-2 -export class Hasher { - private algorithm: string; - - constructor(algorithm = 'sha256') { - this.algorithm = algorithm; - } - - public size(): number { - return crypto.createHash(this.algorithm).digest().length; - } - - public hashLeaf(leaf: Buffer): Buffer { - const hasher = crypto.createHash(this.algorithm); - hasher.update(RFC6962LeafHashPrefix); - hasher.update(leaf); - return hasher.digest(); - } - - public hashChildren(l: Buffer, r: Buffer): Buffer { - const hasher = crypto.createHash(this.algorithm); - hasher.update(RFC6962NodeHashPrefix); - hasher.update(l); - hasher.update(r); - return hasher.digest(); - } -} diff --git a/packages/client/src/merkle/index.ts b/packages/client/src/merkle/index.ts deleted file mode 100644 index f97e41f8..00000000 --- a/packages/client/src/merkle/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* -Copyright 2022 GitHub, Inc - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -export { Hasher } from './digest'; -export { verifyInclusion } from './verify'; diff --git a/packages/client/src/merkle/verify.ts b/packages/client/src/merkle/verify.ts deleted file mode 100644 index 51f2ed5a..00000000 --- a/packages/client/src/merkle/verify.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Hasher } from './digest'; -/* -Copyright 2022 GitHub, Inc - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Implementation largely copied from -// https://github.com/transparency-dev/merkle/blob/main/proof/verify.go#L46 - -// Verifies the correctness of the inclusion proof for the given leaf hash -// and index relative to the tree of the given size and root hash. -export function verifyInclusion( - hasher: Hasher, - index: bigint, - size: bigint, - leafHash: Buffer, - proof: Buffer[], - root: Buffer -): boolean { - const calcroot = rootFromInclusionProof(hasher, index, size, leafHash, proof); - return calcroot.equals(root); -} - -// Calculates the expected root hash for a tree of the given size, provided a -// leaf index and hash with corresponding inclusion proof. -function rootFromInclusionProof( - hasher: Hasher, - index: bigint, - size: bigint, - leafHash: Buffer, - proof: Buffer[] -): Buffer { - if (index >= size) { - throw new Error('index exceeds size of tree'); - } - - if (leafHash.length !== hasher.size()) { - throw new Error('leafHash has unexpected size'); - } - - const { inner, border } = decompInclProof(index, size); - if (proof.length != inner + border) { - throw new Error('invalid proof length'); - } - - let hash = chainInner(hasher, leafHash, proof.slice(0, inner), index); - hash = chainBorderRight(hasher, hash, proof.slice(inner)); - return hash; -} - -// Breaks down inclusion proof for a leaf at the specified index in a tree of -// the specified size. The split point is where paths to the index leaf and -// the (size - 1) leaf diverge. Returns lengths of the bottom and upper proof -// parts. -function decompInclProof( - index: bigint, - size: bigint -): { inner: number; border: number } { - const inner = innerProofSize(index, size); - const border = onesCount(index >> BigInt(inner)); - return { inner, border }; -} - -// Computes a subtree hash for an node on or below the tree's right border. -// Assumes the provided proof hashes are ordered from lower to higher levels -// and seed is the initial hash of the node specified by the index. -function chainInner( - hasher: Hasher, - seed: Buffer, - proof: Buffer[], - index: bigint -): Buffer { - return proof.reduce((acc, h, i) => { - if ((index >> BigInt(i)) & BigInt(1)) { - return hasher.hashChildren(h, acc); - } else { - return hasher.hashChildren(acc, h); - } - }, seed); -} - -// Computes a subtree hash for nodes along the tree's right border. -function chainBorderRight( - hasher: Hasher, - seed: Buffer, - proof: Buffer[] -): Buffer { - return proof.reduce((acc, h) => hasher.hashChildren(h, acc), seed); -} - -function innerProofSize(index: bigint, size: bigint): number { - return (index ^ (size - BigInt(1))).toString(2).length; -} - -// Counts the number of ones in the binary representation of the given number. -// https://en.wikipedia.org/wiki/Hamming_weight -function onesCount(x: bigint): number { - return x.toString(2).split('1').length - 1; -} diff --git a/packages/client/src/tlog/verify/merkle.ts b/packages/client/src/tlog/verify/merkle.ts new file mode 100644 index 00000000..d0907488 --- /dev/null +++ b/packages/client/src/tlog/verify/merkle.ts @@ -0,0 +1,128 @@ +/* +Copyright 2023 The Sigstore Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import crypto from 'crypto'; +import { VerificationError } from '../../error'; +import * as sigstore from '../../types/sigstore'; + +const RFC6962_LEAF_HASH_PREFIX = Buffer.from([0x00]); +const RFC6962_NODE_HASH_PREFIX = Buffer.from([0x01]); + +export function verifyMerkleInclusion( + entry: sigstore.TransparencyLogEntry +): boolean { + const inclusionProof = entry.inclusionProof; + + if (!inclusionProof) { + throw new VerificationError('tlog entry has no inclusion proof'); + } + + const logIndex = BigInt(inclusionProof.logIndex); + const treeSize = BigInt(inclusionProof.treeSize); + + if (logIndex < 0n || logIndex >= treeSize) { + throw new VerificationError('invalid inclusion proof index'); + } + + // Figure out which subset of hashes corresponds to the inner and border + // nodes + const { inner, border } = decompInclProof(logIndex, treeSize); + + if (inclusionProof.hashes.length !== inner + border) { + throw new VerificationError('invalid inclusion proof length'); + } + + const innerHashes = inclusionProof.hashes.slice(0, inner); + const borderHashes = inclusionProof.hashes.slice(inner); + + // The entry's hash is the leaf hash + const leafHash = hashLeaf(entry.canonicalizedBody); + + // Chain the hashes belonging to the inner and border portions + const calculatedHash = chainBorderRight( + chainInner(leafHash, innerHashes, logIndex), + borderHashes + ); + + // Calculated hash should match the root hash in the inclusion proof + return bufferEqual(calculatedHash, inclusionProof.rootHash); +} + +// Breaks down inclusion proof for a leaf at the specified index in a tree of +// the specified size. The split point is where paths to the index leaf and +// the (size - 1) leaf diverge. Returns lengths of the bottom and upper proof +// parts. +function decompInclProof( + index: bigint, + size: bigint +): { inner: number; border: number } { + const inner = innerProofSize(index, size); + const border = onesCount(index >> BigInt(inner)); + return { inner, border }; +} + +// Computes a subtree hash for a node on or below the tree's right border. +// Assumes the provided proof hashes are ordered from lower to higher levels +// and seed is the initial hash of the node specified by the index. +function chainInner(seed: Buffer, hashes: Buffer[], index: bigint): Buffer { + return hashes.reduce((acc, h, i) => { + if ((index >> BigInt(i)) & BigInt(1)) { + return hashChildren(h, acc); + } else { + return hashChildren(acc, h); + } + }, seed); +} + +// Computes a subtree hash for nodes along the tree's right border. +function chainBorderRight(seed: Buffer, hashes: Buffer[]): Buffer { + return hashes.reduce((acc, h) => hashChildren(h, acc), seed); +} + +function innerProofSize(index: bigint, size: bigint): number { + return (index ^ (size - BigInt(1))).toString(2).length; +} + +// Counts the number of ones in the binary representation of the given number. +// https://en.wikipedia.org/wiki/Hamming_weight +function onesCount(x: bigint): number { + return x.toString(2).split('1').length - 1; +} + +// Hashing logic according to RFC6962. +// https://datatracker.ietf.org/doc/html/rfc6962#section-2 +function hashChildren(left: Buffer, right: Buffer): Buffer { + const hasher = crypto.createHash('sha256'); + hasher.update(RFC6962_NODE_HASH_PREFIX); + hasher.update(left); + hasher.update(right); + return hasher.digest(); +} + +function hashLeaf(leaf: Buffer): Buffer { + const hasher = crypto.createHash('sha256'); + hasher.update(RFC6962_LEAF_HASH_PREFIX); + hasher.update(leaf); + return hasher.digest(); +} + +function bufferEqual(a: Buffer, b: Buffer): boolean { + try { + return crypto.timingSafeEqual(a, b); + } catch { + /* istanbul ignore next */ + return false; + } +}