From 89a165bf56cd38495fdd8f78a0001486e13c4665 Mon Sep 17 00:00:00 2001 From: Cybertech <131432994+cybertechsoft@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:19:02 +0530 Subject: [PATCH 1/4] Use canonical loopgrid-verify package --- LICENSE | 211 +++++++++++++++++++++++++-- README.md | 45 +++--- action.yml | 14 +- test-fixtures/tampered-evidence.zip | Bin 1602 -> 9821 bytes test-fixtures/trusted-public-key.pem | 2 +- test-fixtures/valid-evidence.zip | Bin 1602 -> 9811 bytes 6 files changed, 225 insertions(+), 47 deletions(-) diff --git a/LICENSE b/LICENSE index b28f92c..d645695 100644 --- a/LICENSE +++ b/LICENSE @@ -1,17 +1,202 @@ -Apache License -Version 2.0, January 2004 -http://www.apache.org/licenses/ -Copyright 2026 LoopGrid + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ -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 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - http://www.apache.org/licenses/LICENSE-2.0 + 1. Definitions. -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. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md index 18ca242..35d6215 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,7 @@ Verify a LoopGrid evidence bundle directly in GitHub Actions. -The action runs LoopGrid's offline verifier and fails CI when evidence integrity -verification fails. +The action installs the canonical [`loopgrid-verify`](https://pypi.org/project/loopgrid-verify/) package and fails CI when evidence integrity verification fails. The Action does not carry a separate verifier implementation. ## Quick start @@ -17,25 +16,23 @@ jobs: verify: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - name: Verify evidence - uses: loopgridio/loopgrid-evidence-verify@v1.0.0 + uses: loopgridio/loopgrid-evidence-verify@v1 with: evidence: ./evidence/evidence.zip ``` ## Pin signer identity -An embedded public key can establish integrity under that key, but it does not by -itself establish that the key belongs to the signer you intended to trust. +An embedded public key can establish integrity under that key, but it does not by itself establish that the key belongs to the signer you intended to trust. -For stronger signer identity verification, pass either `expected-key-id` or an -out-of-band trusted public key: +For stronger signer identity verification, pass either `expected-key-id` or an out-of-band trusted public key: ```yaml - name: Verify evidence with trusted signer - uses: loopgridio/loopgrid-evidence-verify@v1.0.0 + uses: loopgridio/loopgrid-evidence-verify@v1 with: evidence: ./evidence/evidence.zip trusted-public-key: ./keys/loopgrid-production-public-key.pem @@ -52,28 +49,32 @@ out-of-band trusted public key: ## What verification checks -Depending on what is present in the evidence bundle, the canonical LoopGrid -verifier checks cryptographic signatures, content commitments, hash-chain -continuity, signer identity metadata, disclosures, checkpoints, and optional -RFC3161 timestamp evidence. +Depending on what is present in the evidence bundle, the canonical LoopGrid verifier checks: + +- signed Evidence Bundle v2 file attestations; +- SHA-256 digests for exported evidence files; +- attestation digest and signature validity; +- event signatures and payload commitments; +- workspace hash-chain continuity; +- signer identity metadata and optional out-of-band key pinning; +- disclosures and policy consistency; +- checkpoints and optional RFC3161 timestamp evidence. + +Legacy unattested Evidence Bundle v2 exports remain ledger-verifiable with an explicit warning that exported file bytes are not covered by a signed bundle attestation. ## Test fixtures -This repository includes two **synthetic CI fixtures** generated only to test the -public verifier: +This repository includes synthetic CI fixtures generated only to test the public verifier: -- `test-fixtures/valid-evidence.zip` — expected to verify successfully -- `test-fixtures/tampered-evidence.zip` — contains a modified protected payload and - is expected to fail verification -- `test-fixtures/trusted-public-key.pem` — public key for the synthetic fixture +- `test-fixtures/valid-evidence.zip` — signed, attested bundle expected to verify successfully; +- `test-fixtures/tampered-evidence.zip` — same style of bundle with a modified `report.html`, expected to fail with bundle file digest mismatch; +- `test-fixtures/trusted-public-key.pem` — public key for the synthetic fixture. These are not customer data and are not production evidence samples. ## Trust boundary -Successful verification proves the integrity/provenance properties implemented -by the verifier for the evidence supplied. It does not prove that an AI decision -was correct, lawful, or that every real-world event was captured. +Successful verification proves the integrity/provenance properties implemented by the verifier for the evidence supplied. It does not prove that an AI decision was correct or establish legal compliance. ## License diff --git a/action.yml b/action.yml index d980cb8..0276435 100644 --- a/action.yml +++ b/action.yml @@ -34,11 +34,11 @@ outputs: runs: using: "composite" steps: - - name: Install verifier dependencies + - name: Install canonical LoopGrid verifier shell: bash run: | set -euo pipefail - python3 -m pip install --disable-pip-version-check -q -r "$GITHUB_ACTION_PATH/requirements.txt" + python3 -m pip install --disable-pip-version-check -q "loopgrid-verify==0.1.0" - name: Verify LoopGrid evidence id: verify @@ -56,13 +56,6 @@ runs: exit 2 fi - VERIFIER="$GITHUB_ACTION_PATH/verifier/loopgrid_verify.py" - - if [ ! -f "$VERIFIER" ]; then - echo "::error::LoopGrid verifier is missing: $VERIFIER" - exit 2 - fi - args=("$INPUT_EVIDENCE") if [ -n "$INPUT_EXPECTED_KEY_ID" ]; then @@ -77,7 +70,6 @@ runs: args+=(--tsa-ca-file "$INPUT_TSA_CA_FILE") fi - echo "LoopGrid Evidence Verification" - python3 "$VERIFIER" "${args[@]}" + loopgrid-verify "${args[@]}" echo "result=VERIFIED" >> "$GITHUB_OUTPUT" diff --git a/test-fixtures/tampered-evidence.zip b/test-fixtures/tampered-evidence.zip index 8acf6228dbfe8d0108e3c366b9f736cc7180e075..3998a75b0cf2466a1d975fe7f8ce4db558c826c6 100644 GIT binary patch literal 9821 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 literal 1602 zcmWIWW@Zs#U|`^2_%g{b_FBUuoy$PpJVpivULc*Dn3tKBT3n)+Rh*x?R+JP1*N{1%i{wk=YHFBJaOO5pOx>rnzoj&=5+}O=b1E@ zJ8h11wg0aU_RNUZzg;rg-zG9kbun3Ld!*ZV3JNZIuqUSR4w#)A>(<)rYHf)n|9sy5Il!Ze!}o?H^87t4-ZvviGyddgo*MyRv3|%2u3Qet6H^ zhN~8*WJbeBZIxD=Q4>>pUO^`938zIkPx3KM(Bt)z5+g0=ztZ&*_{ybH4k$Pv9n| z;>X@W-QJIQd3dIlIB=c%+`XxrTWE6dLakHX7ez#-KYOMo7TWZrN&B;xQINOMWYIac zqL+-cy}Y`EL_d8l(we#>ixJfUD^7fNWM*Puuw-Un-~l=ywJbHSq!?^{&eGu1dAAJ& z_Ix)k>EpI@2{f7Dy?B{#@{dq!*BZfT-dbWw-P=@d3o!k!4lnH5_59V5t7p!g=~})0 zgnaGQXG^==N^(A5GtWII(VwUEd}Ge3fLMoB>kbC5h@UOHVE=;U&!qNbY`c3dUh00T z-E}$Vw|kVG9Bi&^Ew|iqM8HRdIgKqwzrt!t&WRgc9u2uG7C(&sqIl)UuZTtJPwswN znsc4qT0yLnzv}yo8sWFEK5^W5di%kzc`s{>cAi&%e9?I8+Xc5-3M|{+Zp+y|Wn+K4 z=5~qYL1ID5VrFcj(dUI66sNGAo*UY^QRm{R#>bpdO@@X&S}Whg-?h`qIlOTC)u#I{ z?~TK5Og#N5y=-R3rl82*j{uOpY53%it$T;n)9b0UYEq1Dp3r3*3yF8I8#i+kIjYGGozZsD4L9Gm~wc>IvJ zWmp)L+v_<;OTs`sXzGb0$vL@aCNze5&bf8vms8`GZ4ErT8+n=8aLdJqcp7p6eJVZrb9cf9==pn#b?tH$N`X-+lF6;mCx0hgt6PkvY}+$ALV@73=9{|`T9 zb=ah>?0b2S_4BFQFKYgMkw#4{!1TcYOez;~Lx>S`!0P7fkeS@ZYgJKdsMw z*XpI47wd}O;0U)D=Ltqj=;!KR+n53!U=76FKwOkskY7}ymr;_Nv-zaP$+H_bd!F)N zxn=W7gDsy=p4TtXKjjlx@GV5cQ&%t0Pzrve}do=uJi@5oX-w z7|;qZXaJULXr&suR`fyuqLG22f$<-ZiBcq>Yemn#2(9%%dyz9Ux=xTvrD68=YFS^<)C4(As`^&A#M~o^(c!ARY5Qi5K*WQ5GZfI+8O~Z%>Yi$ z%vMfzz;=ywB}yJ-pU%z?OY^lzt<^|wpgh={0^FM~Uv4&?2?1E+!04Z*>85^kzkI7Q zO?|poZnQ-W3Su# zQ?7fS#LK6hnOX60K&bwJ1-<^IUOh|>HfSTMYEsACia|1c*n%oOSG@eaI0a`niLyxX z%?jN55-*?!JLw%Mw49_jQaZZcM)n861+gTs$l6?NfxUtMIawX%DP0n2AU+N*{b&aA zspw3JQhAP8Qh@8ZR^o*|Nmzh){I1Wk-m$!G-NC0%T(!N3AiM}}cqpRZ@ORcdLw;;x zffCMJL=W=Pm_xU-VU+b(?8d+Qjwp6BgL^FC`uyVEp>3yWK6<-yV!~>!XMaxNMe?-h z$4z`tv-AMe#P893)Ft^Y^R3CrpTRDKjl^3+Syt`zod-*zlZlLmKt89d+WlDs3u_n3 z;Su>W4Qi0zubOIZF#tv;swb%Nkxz61yFe^iWW(_cxaX+wiHek2 zq?h?iALS_(aqg3*62F54p(Hpc-lWN31lp*&?d-V#jg+xyq3%<%$TwT@s%^PLtb|IK z*;n)!_wEe)sZ}3-<}-v|YBrYP&zspWtt<&S#wQgQ+9atoNS#391s%qCgoy~u z8SW$=fsyidOy{r+@2PCk5Z+tlJ5pWGsYMf_gR>pqV;X}anf0};+m&HfSRfzQ!sb`> z%vcw)PiBzQeib^7*}?tm#MM{Am5bvV^T=>o16r*Mg3JS+PLhMYsND^`Y|_9CiFI(# zs6GKs&-|!;^Hi=~v3h(nDe>3V z^`yzg!9+!6>`e%1~-0tk3A~)8cF*u3IW%r;DevJ5xIh$y-)BngRiEkRQSu!*3vo=7a zvrasY<6#=~nw-hlYwishp;OgKdQy^3#Q07_~lbiFzyGgAo zYL&+GEu|N7%F+|4h*%YSRTIncyw!B`CmFERDwrK0to@J@z(y*DN}%nrIV!{@Nb>ci&X&ju%ZXufjl7S>Y0$-T* zq7sd*E#_7nHT@){wq$jPm8>>#d8OrZ{*h(4WiIgUeU|f2h3ogM@5W`Vpqa+>>fpA7>gRF@;j#xGJcfeX#>aTkNA+6 z46;EFZ9C#Z(@&ZoeIWiF$U+M(m1Vd$p~r)OKz<7(z!d;=cKSPvHtngls)Qq$gX`-` z4#SQ5TY|y`hhOwKt;7{LMWZ@$Jk0#gi*ODGY#sAsE|XE*P#3Vwgmx|+i1yW9~C5U-~e11Yr;fQ4lVe;QC&Q6MX4EHTcU^W z;%r@+*^d&#Kl51zA+x_UwH*dd>}q*~;FYv?y);bt$}&`sKFxOWoF#S9MoL>yPPv*8 zYsYovn$r`AY$RfZ5wQ$mFnlszqQpvdWh{E4AX0MBPpm5=dQy&j;T~1@<|^`Sz>h~( zv8S}t9HuuV{yKxMI|CVi2PUu|0>mY{26N5V-z);MBV%4(R?6~t0v?Z4X7jH`tOijB zQ>=K?o?iTgp6d}#FSq*l9pl&1Ue><3@Y`0AW*ytb#EtD2fxK@vS8No2A0 z^(H^{mfMS^+^6`0eEOOjdmP@jKz90ZSQyCtxK7GF6yv~mB^MCBkWN69lvjhFQD)?@ z2swbOPonlC#P-Ufa1;TqeJE3B8esoMnABkL#Y`=+?eHzDR41jykPd|E&&gD=lS}8y zfutrKwN)c*QlI$DO_kprv=MA(6x6P<2GdC%#9HTqSh@Z2)TJnuN`v4B*z=}c5q8HR zcgArGxB0cCZrH*%2ms{`xCJ}gR1|e-(2kKqaF!He@R0%FT5Qqa_msY0A0Df{y+<*1Bq$s5vrtg+N= zICcR53**0_@{@BA6-I@O5IF%2hSm)~Y7OxAPif|>K+>AE<7P=6S|_@pXu=?Y#@Q@J z+D!b-!>w4OD+j9ykFrAc>8=zg=di5B&`}VdIFA^fTEH%1+%nAYhNF4vbJ7VoF}j|S zD?vYPFVhfZA_<)8C)vi-sKrsnyR{m>-t3WGk$&)}!QcS6)el#LIroH!l$a;0_6iIG zks@_4>Gm1ZG_Vn*`sqFAu7z$xZ=Az58tYUwocluVnAT7A&~A~`+l04f?d|TYFzwLR zujgYfo0)zW-AIZD2e7c1Krt zwO)^}fr9JYToy2Q%pJ{qbJ_Z#PB++lf~xS)v1G=&Dp|3djs^HTa*p~bJ2?@J_~xBc z6HLtw$X5f0Xf5qoxF#zlhKXVxPefliO1UnP#Z@-4B=v`}4t+U~wHfF@#sQI>+(@#0 z=*X-o15jc}b+z?G6=M9p z)Rym4;@vqokIQqueeOx{94Dyl{TXJjD|YX6ejk%=1TJLz@_CUg$SB|@I>8Vp>w{>9 zbi_pcuxUg5Bqd55Rp$2;PW&cmLKsqv4q?7*TZ5qC)joY3yS`Iiy?QkU{jk7~O9ZF< zcI%DIE?QSN50fXehI>s|Zp?D!XJhYryYmPSn$3Ks#q9j^U^V9ZA8n2R!A{TEn_4i; z@|@h9=J}YjzMiSxlZyIdR(S}mfD-#hq;}nSe%+;!atXDqbAeq0(t5T*%Qz(P%e#HQ zoHnW++O*6ePA^XirUOGsl@FyONxRH6N_te7(Q6Qe?HD3wGYsvTDA(^x&&`?CSh0bJ z+KGn<@$Q7zNuX!aobp5u@zZ?n*pF;e_eqQh58FwS>6fa83Va)*@Ua-7+)6zeUxYvg z)Qj2BBiT%585La7G-C=^XcUgKzekbd;H;k4j^x8gU@wbqRi2gO1F#)cX}Cs?YfJ~H zCo5NM^ipGJ>`cvHi|TwwA1-dYPX_ZJAYx|HS~ttiPgdK1-|iPJ5#xj`RH9C8TC5pg zjXiy=V^S4xZ*}65+e7Mm)NWH|;4yfC`gaoWw(mng{dIf0u-=-9g^?wY$<5Lk2yptJ zTmbryykYMi#S8u|`O1B3NNF=IOKs- zX!Lw;z&E)<*S+r6TS-i0!*f%F%c8T=Fg^knXQ|vTJ#yg1Ad;N%pS!b<3oZel2%?PP z*FrrEEsTp)zw-AiKVeAjL2ja{G1Iy)ke3Y63y7Y5mb`D)?#WjocO5{Oiu@f5W9o?+ zuv$8A<<|xm$c!zrD6P8Y8jMVirebW`9`GqNwK%X17J#6dXqr#xzElVg{7O>cXDx*5 zB}+w?qAxbA6I!c#v1Wt8-|?%2)1h_H#)Vq-n~x9Vzhj)s8^7WC7UW=f2ndX~fVl!3 zEzK-VjGX^BYi(E8vs)&^2Ax%j_~^}10l@{J@&*}Dn!06-Y7njNG9y0vfL43iD+uow zui~NNQsVK@uPmoc`_A(#Sn@CkW@}5%qz}zua{OE6JH}bN;6iU91ZCJyN14k#1An4W`{Pu{u+{2mjulM<@kR~_18R?qEa0q>w59cO z0DGT@T7>HDRkdJAQ0p|ja233ops~GRa9G|O7hkvzMyB4Ub~aIGzJ%Ith0af9y>lZi zv#1?>TD5}?id135h0{jczKzeY2Pz#@rwnC?5gHKQB?l|z^I?4B? z_{A!hC3>~ zao0VC3W;=7)Gk~hh4ose#`rLC^Q##-uj!$*74$!=-(5yUTkF9^KF7QzO^?O2*T#g05b6~u>t(OxYd4& zqC$})2W>us1FCOfPCo{v#cF49;EM$G8&a>Wva_)PJ3FhEO(Q|@2*;C}f`L+IydA}u zx$gKMr(hkfryDa~nM>$Q328nPxqf0X^milB;{@X*F~}CAe1hYtc+%%HCCTpBqhJ~t zpHHS0xNO{op2ja3pMHY=_b7=3FRb+5M(6N9qqKK1wy`u}vIclC+XHN;XJMgeG~ASg zgZz|fNR`ZC?dGLpQ<+n$oP3&94GSU~RWzGT3d>E>WF*3+zKDKK%k>dgbk9|F_4Kd| z3bSiSQ3^6QllCceQ;(}rtqC(yu`gi4TY%RP#zR*9V_kFWs;<@{LO=`>|F;l20_^P^ zotZ71ZEZ%iR-?BXa5~0Ss|o58Y?*XsI!&wMI0Z5oV-*5y;xqE_mA7*%qF?AIIkxbj zuZfpNI;75?Xg%UdKG*&h;An-f-tYf`+3)0jpLzCMnd2$5q)B^TB)ROzUfm0#{7-XO zTFLu(E~b){6b7n_{aR8cEl3s?JC3IEa!EZ*{Uk(LuuBTUcW=54?k&T#5EXSZdXwf^ z$K+nQaQZJrj;X$?YBw4R)bNV9%8q2O&uAE3Rdg{uT~3tdsCPCr;oi898$T8cu)yD~ zqJ*<471T4nLk90t zcNx5$OGyz)N%2rb!lv{gga-E{N7%=cvhL0Y6|`4%Y;Wb%nd9rNU^sE31q+fi?GWb} zYuRgx>9%+xinQO93@~cm?!i*jHe6mrDu!erPi35}T-A|1QFE5S6Kcqz4NxB$p0mmw z+Wukek{LK#sRP@;KW_0jsxz>jHaJf!^mt9oNKYo*{@`4XW^E6O=V zFFy#F4NK0h71T*o4=(yHIc9}pJyQ=StL75mMP-|mKXPZdU%`r1pNY0rZ&bKo;r>nF z2d-R_c!%xA)W#<?(3n7K;JruFEmB^7>#Mk2sBPaR$!@(p|rlaQ`+wAP+(J_3&^(hq5sGj{xRtl^72_#O`~j74je zxe8yGcl*+qo&PAYEhS1z3UKa}El)4g1#9i5&UsOuim{%SR*xUF(;{$PxVs>sOd|^f zT(slQOL&ZwzZ;WWs*%YupVNivv_!i~k&$#m*`))qAuzl*?oVBY4UYqL+KYIwi%gK$ zsybxtEvIlLYWJIMc*QQ=vP~O@HeAWMQoEwZslrYh$O#`-)A1X4UM$qArxKrF3cU=W*}X+cY2@7By)oD<2>DMYSM#xH^A-a3Vrr zx2aWHo}sYp7uyl6B~mi!;37fO9TLQ2v!o&_Y-jnFv(6wIy8GQD)0&QOmvK^^*){HP z+fv{TFl=Yah6HoQQkuX0(?~@W3e4lScdE{M1U4Mi!mP}`5%&TA*kXZoq)Tr|6sxP2UODopH+8L! z6Zv~pucyl?PPUX!2?81KmG&P~MjjY8v@+CoDp9hIfetC`BmPY$%a|w$W%G3fSLG+* zcdSz3Y=ly1xU5b3W7&r#E`y+y(jCmpiXa--bTRp|jFE?`*B6?HhrGd+a#*T)!N%f!*=Qk0NOU2@${(eAr_r;ZmC#10)g zeH{C5n@{C-2JH{Gpq~$i8k|&U>BgJ>u36u;wX_sIpFPnmH8; zmyktGmBzwu!Z4%1)~<(tbmyl?VU}~f@b)pL0CiiBJ8)mhXp8zRRQVA5R$swzh4T1N zmRoR&@R1@S!DLX9*$xR(Bx^iG`Jnd|0=e(~Q(4bpgqntPBZA5(qGT_F2|}o~2#i{5 zCe|@^iEPbpemHrC(XBel5l7SQ^FtQj)%I5Zn;YQ6Dc;wuCr(AEgSsvkT`6&rtAJ{+ z)&vQfBzdRj^c|$pE`USHewnJXBI?QPPmxZp5cUP`jpU6s%H%U5)n7_BxMYj;8iPrj3zRRY=G^ zpFEm}uh!dU(<`pR!;aL|_)mzN>$lSfrM-_m-Sxlkb1}}A1RLVX=)Yc%@|c>>D_W`} zPUvy7A3eIpEQc%|ahc`BZ)@hb;7yL|#vI+Cr)?S3K590O89caz*-qqgKi`<;UM*I- znlp8KQ0gVfe~*>x35`3AcPXkv!bq(nvZ;#%ZBZy82W>@Y+YOctdn{c1DxMQRKSsew%G0P;+ARVXiE9`~fpV+qsNRt>`(6LshaG&+@|X%@5x@ zf*iSJRZ2q;_!x4$LhualM6#s=eHn_<+@we>C?kQ<*!$zE=|}>&-&8t6N_7%%giUgY zAc5%mJ`nl23x{MYz|$Y=zJo!}FnW*GC=jW^zQk|^%})yIj6$(AcE%}&F6Yihchclc zFzO=;^kJ6qdeQ{T6ZPgRx+h`8LWm|$VHVy}K(ipS>MjWW+TX7DgA2ix-s&2=6sbKv zK>x7VKj9JM7pfgD%iSs&|SmGM{u_M2!C%l!I_JhzZs)?+TQl&fqZ1%ObTcWip^{4T3|{mT0l`?d08FLIeq3+Os!7d9ZK z0y$)yfW;8`PCn$vU_VLjg^9s^Rz!1;2|7X-tKu|T_ZprUS^IaWp|~5m7k2WrqrRGY z=xnbVyg{)X)qq5Q;s6G*b;xmtAnXsGs9vuG$GxP!ReoO61z^4N|4lYyJAdad8*KNP(4^%K${#!+P8wV}2*sQV z!$^r*3`~FbGn!Hk5X*1Ln7`nae9=8!(mE40&e|cIi8>hkaii6j(sKVP|Lg3H)P%S0 zIn|xN%=*ogpGmrXURN)e1&jun4SDqo{TX)=i2Z{M+rbZ3ph#0kPo8e(%92vz_&hZ~ zVaty%j+htOH}19OiuOLCV>#`5RPAok`tEF4$LoDmDU}E*!nbrTM6`D%wZ7p~a7t-h z5^}YGqT1Gff|caJRh$Tymb#ckcxT-2V1-;+>ix%*N?tdX@btnq(c{xlEjl{SIBBq)|{ z?(kQ>`bh$Img)EDtl)W6-W2=>dbUGMIpy$>=Jbf1P`*O)6s7x(GhtePNKlSxxp(4q zz}o>qH5gAQO&drtUA@uvd;g2k0WcXkJHMS1_{Yw$ONNg)THU10|Ub(Wnr?9HII z#cVT4plB4Z@-2bLs39h{?a44Fvb7l7t8y|)zo7b|A~K0Dm$SEppLY5e7rzdu1jh{k z**b~csnIw+Jh%_=B6e2G%2NOztJ=ZB5iRuzXKrC3nXe;p3L1bhM<4iR=a8@TD=4kw z&MtVBBRV5=ADRZX@gwEvl#zceksGyYuA<2q^8q-u@3xGbBzupOA6xs&h|JMiPtZE$ z$Mw2;U5`8ou21mv_AmW+^`;S-Ur2DTK9;BJDnw|827V8v5`Z3SQI@6W^9mP&nC(&c z{6Xp~^Ne!Ba75*MY$1k27}S6P`_jcCzqM8i?eAgTUKG)bQWg+Jv740I9vDO2>~tAJ z>=PJ_L}@X-B+F;8M6l`c=^Uy4Oy8GS`J;=tv9yQZQxHzIh@B^kU1HZ_L8*<+%~Pb9 z&y0>YuJWdA66kxgA#XTZ*({0`sT0}{6J#{J)1`!D@QanZ?SkRkwvm(XPQvcN5O#KR z(#mWh|G|~-f*}K=J96!!pC zy7Xk@in4gh3<&8c`MNn?DSxe|!kJ@K9Ek`vl){}@#|i&FiZ%`FbF4eZ4}*HM-prDc-t&fw0 z9EWwJfxQb>)rHqYCZyh-vHBszX~GybEyXp1@iaJlU3^p?apAC{G!A=G1{r@?27nX& zh*@u+5dR39St}hPm9!YnRw+ss9mQ%s0)-Xivr0M`$kdT3b@HP#M0=8a;kVaM^4B(_6i6mK>zUqw&*ZatcbTNQ zrY`NHu<mDil};hGhZR0 zu%Z9|b2@M9@bAm-O|Abpweu(XXX*UEK?n#x$db2%KmRJE|B3!tEdB?*coT#FEFJ$z z{`KU4I?Vr&w{PY9|HE(ovx7hV=6^b%do!v2!+rh}{L^>+2h>LUKU~;9@ju?R+JP1*N{1%i{wk=YHFBJaOO5pOx>rnzoj&=5+}O=b1E@ zJ8h11wg0aU_RNUZzg;rg-zG9kbun3Ld!*ZV3JNZIuqUSR4w#)A>(<)rYHf)n|9sy5Il!Ze!}o?H^87t4-ZvviGyddgo*MyRv3|%2u3Qet6H^ zhN~8*WJbeBZIxD=Q4>>pUO^`938zIkPx3KM(Bt)z5+g0=ztZ&*_{ybH4k$Pv9n| z;>X@W-QJIQd3dIlIB=c%+`XxrTWE6dLakHX7ez#-KYOMo7TWZrN&B;xQINOMWYIac zqL+-cy}Y`EL_d8l(we#>ixJfUA{-~Yn3)(DESVV?cz_N_ElbTSDF$1gvo!d0-faVc zJ>QK>wsG6J1e#3nUcAg#_{UWh*BZfT-lxQ5y0^)h^(g$mH+AZwmpiQrO*b2Fe)P)p zlY9Ku%9o2cWpD5K>O0%`*y6w^Hq(41S1~SmrCh=Fb=ytmUyQb<@&_F^+rIyMp4_>-#;!M_7V%)Z{Pi_6h-2snZCwI=9(EW5* zj`>_!;qD{H_f~%~>-tt&$zt<-_k&;aeioVSKAC>`cY4gh}j@l5^y>RVu<_^!AxZnG{u z~>Br~aG!RT(y6Ky1_)fe0$NU#tzVCF_sAA;_y6dE= zSh?+(ik~RY4B?etDUS@dnWdd@Sgqohn^n8eAu8JADDUM%ubZa1ea*f+b?@}` zZ0@|)ubzA`WNqI2M8R3lb&M0gWwh4ac#(fvyu5h2>~r?YxVP`+=I8NuYc*e9|GsCpCnfBxC?tXDgc zJnhwtcd-WR_w?SoXZM#4HL(EG2LmvzfPpZOOwLHm%+oE;EXhkPhNc;222?G2yIhK5 zfl3R3m=A~xN|SOjlXbIGEA_qT6t2vC>zPiM`)?lED8-t_gwbvuI- zmUM3qjM$mHbaissy2QvTuRhm=u+XXLGm9tI?F@`-aBpZ$3`|`x(YwKa$L{>JKKEU# zmu_CHD}I9`++Lg~7%icntAA}{3Uq)q5OV`@QEEYcQHfqgNp8;OlNu+_Zrtp7%75jS z%_j}Ed_H+zzd-+#Phi2f5DiaVy+DuCYcw=?Q%0aS8JR?wahGF2 zE5M)uSgxU!YUo|474 From ec6aa2489595f2da788bd9b9a8049cf119888d86 Mon Sep 17 00:00:00 2001 From: Cybertech <131432994+cybertechsoft@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:21:08 +0530 Subject: [PATCH 2/4] Remove obsolete verifier requirements --- requirements.txt | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 requirements.txt diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 0bffe4e..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -cryptography>=42,<47 -asn1crypto>=1.5,<2 From ccae9d472b58c6c434fc8a6acea0f8a686a3fcb8 Mon Sep 17 00:00:00 2001 From: Cybertech <131432994+cybertechsoft@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:22:04 +0530 Subject: [PATCH 3/4] Remove vendored verifier implementation --- verifier/loopgrid_verify.py | 233 ------------------------------------ 1 file changed, 233 deletions(-) delete mode 100644 verifier/loopgrid_verify.py diff --git a/verifier/loopgrid_verify.py b/verifier/loopgrid_verify.py deleted file mode 100644 index 01479b7..0000000 --- a/verifier/loopgrid_verify.py +++ /dev/null @@ -1,233 +0,0 @@ -from __future__ import annotations -import argparse,base64,hashlib,json,subprocess,tempfile,zipfile -from pathlib import Path -from cryptography.hazmat.primitives import hashes,serialization -from cryptography.hazmat.primitives.asymmetric import ec,utils -from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey - - -def canonical_json(obj)->bytes: - return json.dumps(obj,sort_keys=True,separators=(",",":"),ensure_ascii=False,allow_nan=False).encode("utf-8") - -def sha256_hex(data:bytes)->str:return hashlib.sha256(data).hexdigest() - -def _lines(z,name): - try:return [json.loads(x) for x in z.read(name).decode().splitlines() if x.strip()] - except KeyError:return [] - -def _json_file(z,name,default=None): - try:return json.loads(z.read(name)) - except KeyError:return default - -def _policy_digest(policy,workspace_id=None): - if not isinstance(policy,dict):return None - required={ - "workspace_id":policy.get("workspace_id") or workspace_id, - "policy_id":policy.get("policy_id"), - "version":policy.get("version"), - "rule":policy.get("rule"), - } - return sha256_hex(canonical_json(required)) - - -def _public_key_id(public)->str: - if isinstance(public,Ed25519PublicKey): - raw=public.public_bytes(serialization.Encoding.Raw,serialization.PublicFormat.Raw) - return "ed25519:"+sha256_hex(raw)[:16] - if isinstance(public,ec.EllipticCurvePublicKey): - pem=public.public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo) - return "aws-kms:"+sha256_hex(pem)[:16] - return "unknown:"+sha256_hex(public.public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo))[:16] - - -def _verify_sig(public,algorithm,hex_hash,signature_b64): - try: - sig=base64.b64decode(signature_b64);digest=bytes.fromhex(hex_hash) - if algorithm=="Ed25519": - if not isinstance(public,Ed25519PublicKey):return False - public.verify(sig,digest);return True - if algorithm=="ECDSA_SHA_256": - if not isinstance(public,ec.EllipticCurvePublicKey):return False - public.verify(sig,digest,ec.ECDSA(utils.Prehashed(hashes.SHA256())));return True - return False - except Exception:return False - - -def _verify_rfc3161_imprint(raw: bytes, expected_digest: str) -> dict: - """Validate RFC3161 status, SHA-256 algorithm and message imprint without OpenSSL.""" - try: - from asn1crypto import tsp - - resp = tsp.TimeStampResp.load(raw) - status = resp["status"]["status"].native - if status not in {"granted", "granted_with_mods"}: - return {"valid": False, "status": status, "reason": "timestamp_status_not_granted"} - token = resp["time_stamp_token"] - content = token["content"]["encap_content_info"]["content"] - info = content.parsed - imprint = info["message_imprint"]["hashed_message"].native.hex() - algorithm = info["message_imprint"]["hash_algorithm"]["algorithm"].native - valid = algorithm == "sha256" and imprint.lower() == expected_digest.lower() - return { - "valid": valid, - "status": status, - "algorithm": algorithm, - "imprint": imprint, - "gen_time": str(info["gen_time"].native), - } - except Exception as exc: - return {"valid": False, "reason": "timestamp_parse_error", "detail": type(exc).__name__} - - -def _signed_body(e): - return {k:e.get(k) for k in ["event_id","decision_id","workspace_id","event_type","occurred_at","actor","privacy_mode","payload_commitment","payload"]} - - -def verify_bundle(path:str,tsa_ca_file:str|None=None,expected_key_id:str|None=None,trusted_public_key:str|None=None)->dict: - failures=[];warnings=[] - with zipfile.ZipFile(path) as z: - manifest=json.loads(z.read('manifest.json')) - events=_lines(z,'events.jsonl');witnesses=_lines(z,'chain-witness.jsonl');disclosures=_lines(z,'disclosures.jsonl') - signer_doc=_json_file(z,'signer.json') - verification_doc=_json_file(z,'verification.json') - lifecycle_doc=_json_file(z,'lifecycle.json') - policy_doc=_json_file(z,'policy/policy.json') - bundled_pem=z.read('public-key.pem');public=serialization.load_pem_public_key(bundled_pem) - computed_key_id=_public_key_id(public);manifest_key_id=(manifest.get('signer') or {}).get('key_id') or (manifest.get('integrity') or {}).get('key_id') - bundle_schema=manifest.get('bundle_schema') - if bundle_schema and bundle_schema!='loopgrid/evidence-bundle/2': - warnings.append({"reason":"unknown_bundle_schema","bundle_schema":bundle_schema}) - if signer_doc and signer_doc.get('key_id') and signer_doc.get('key_id')!=computed_key_id: - failures.append({"reason":"signer_document_key_id_mismatch","signer_key_id":signer_doc.get('key_id'),"computed_key_id":computed_key_id}) - if lifecycle_doc is not None and manifest.get('lifecycle') is not None and lifecycle_doc!=manifest.get('lifecycle'): - failures.append({"reason":"lifecycle_document_mismatch"}) - if verification_doc and verification_doc.get('decision_id') not in {None,manifest.get('decision_id')}: - failures.append({"reason":"verification_document_decision_mismatch"}) - if policy_doc: - digest=_policy_digest(policy_doc,manifest.get('workspace_id')) - declared=policy_doc.get('policy_digest') - manifest_digest=(manifest.get('policy') or {}).get('policy_digest') - if declared and digest!=declared: - failures.append({"reason":"policy_digest_mismatch","computed":digest,"declared":declared}) - if manifest_digest and digest!=manifest_digest: - failures.append({"reason":"manifest_policy_digest_mismatch","computed":digest,"manifest":manifest_digest}) - if manifest_key_id and computed_key_id!=manifest_key_id: - failures.append({"reason":"manifest_public_key_id_mismatch","manifest_key_id":manifest_key_id,"computed_key_id":computed_key_id}) - if expected_key_id and computed_key_id!=expected_key_id: - failures.append({"reason":"expected_key_id_mismatch","expected_key_id":expected_key_id,"computed_key_id":computed_key_id}) - trusted_key_match=None - if trusted_public_key: - try: - trusted=serialization.load_pem_public_key(Path(trusted_public_key).read_bytes()) - trusted_der=trusted.public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) - bundle_der=public.public_bytes(serialization.Encoding.DER,serialization.PublicFormat.SubjectPublicKeyInfo) - trusted_key_match=trusted_der==bundle_der - if not trusted_key_match:failures.append({"reason":"trusted_public_key_mismatch"}) - except Exception as e: - failures.append({"reason":"trusted_public_key_unreadable","detail":type(e).__name__}) - - # Verify target events and opaque continuity witnesses in workspace-ledger order. - nodes=[] - for e in events:nodes.append({"seq":e['seq'],"event":e,"proof":e['proof'],"opaque":False}) - for w in witnesses:nodes.append({"seq":w['seq'],"event":None,"proof":w['proof'],"opaque":True}) - nodes.sort(key=lambda n:n['seq']) - prev=None - for node in nodes: - proof=node['proof'];seq=node['seq'];alg=proof.get('signature_algorithm') or manifest.get('integrity',{}).get('signature_algorithm') or manifest.get('signer',{}).get('algorithm') or 'Ed25519' - proof_key_id=proof.get('key_id') - if proof_key_id and proof_key_id!=computed_key_id: - failures.append({"seq":seq,"reason":"proof_key_id_mismatch","proof_key_id":proof_key_id,"computed_key_id":computed_key_id}) - if not node['opaque']: - e=node['event'];expected_content=sha256_hex(canonical_json(_signed_body(e))) - if expected_content!=proof.get('content_hash'):failures.append({"seq":seq,"reason":"content_hash_mismatch"}) - try:expected_chain=sha256_hex(bytes.fromhex(proof.get('previous_chain_hash','0'*64))+bytes.fromhex(expected_content)) - except Exception: - expected_chain='';failures.append({"seq":seq,"reason":"malformed_chain_hash"}) - if expected_chain!=proof.get('chain_hash'):failures.append({"seq":seq,"reason":"chain_hash_mismatch"}) - if prev is not None and proof.get('previous_chain_hash')!=prev:failures.append({"seq":seq,"reason":"previous_chain_hash_mismatch"}) - if not _verify_sig(public,alg,proof.get('chain_hash',''),proof.get('signature','')):failures.append({"seq":seq,"reason":"signature_invalid","algorithm":alg}) - prev=proof.get('chain_hash') - - # Verify optional full-payload disclosures against the commitments sealed in events. - by_id={e['event_id']:e for e in events} - for d in disclosures: - e=by_id.get(d.get('event_id')) - if not e:failures.append({"event_id":d.get('event_id'),"reason":"orphan_disclosure"});continue - digest=sha256_hex(canonical_json(d.get('payload'))) - if digest!=e.get('payload_commitment') or digest!=d.get('payload_commitment'):failures.append({"event_id":d.get('event_id'),"reason":"disclosure_commitment_mismatch"}) - - # Checkpoint proof + RFC3161 timestamp. Imprint validation is portable; optional - # certificate-chain trust verification still uses OpenSSL when a CA bundle is supplied. - cp=manifest.get('checkpoint') or {} - checkpoint={"present":bool(cp),"signature_valid":None,"linked_to_bundle_chain":None} - if cp: - cp_digest=cp.get('chain_hash') - cp_sig=cp.get('signature') - cp_alg=cp.get('signature_algorithm') or manifest.get('signer',{}).get('algorithm') - cp_key=cp.get('key_id') - if cp_key and cp_key!=computed_key_id: - failures.append({"reason":"checkpoint_key_id_mismatch","checkpoint_key_id":cp_key,"computed_key_id":computed_key_id}) - checkpoint['signature_valid']=bool(cp_digest and cp_sig and _verify_sig(public,cp_alg,cp_digest,cp_sig)) - if not checkpoint['signature_valid']: - failures.append({"reason":"checkpoint_signature_invalid"}) - cp_seq=cp.get('ledger_seq') - matched=next((n for n in nodes if n.get('seq')==cp_seq),None) - checkpoint['linked_to_bundle_chain']=bool(matched and matched.get('proof',{}).get('chain_hash')==cp_digest) - if not checkpoint['linked_to_bundle_chain']: - warnings.append({"reason":"checkpoint_not_linked_to_bundle_chain","detail":"Bundle remains event-verifiable, but this checkpoint is not bridged by included nodes."}) - - timestamp={"present":'timestamp.tsr' in z.namelist(),"imprint_valid":None,"trust_validated":None} - if timestamp['present']: - digest=cp.get('chain_hash') if cp else None - if not digest: - failures.append({"reason":"timestamp_checkpoint_missing"}) - else: - imprint_result=_verify_rfc3161_imprint(z.read('timestamp.tsr'),digest) - timestamp['imprint_valid']=imprint_result.get('valid') is True - timestamp['status']=imprint_result.get('status') - timestamp['algorithm']=imprint_result.get('algorithm') - timestamp['gen_time']=imprint_result.get('gen_time') - if not timestamp['imprint_valid']: - failures.append({"reason":"timestamp_imprint_invalid","detail":imprint_result.get('reason') or 'imprint mismatch'}) - - if timestamp['present'] and tsa_ca_file: - try: - digest=cp.get('chain_hash') - if not digest:raise ValueError('checkpoint chain hash missing') - with tempfile.NamedTemporaryFile(suffix='.tsr',delete=False) as f:f.write(z.read('timestamp.tsr'));ts_path=f.name - r=subprocess.run(['openssl','ts','-verify','-digest',digest,'-in',ts_path,'-CAfile',tsa_ca_file],capture_output=True,text=True,timeout=10) - timestamp['trust_validated']=r.returncode==0 - if r.returncode!=0:failures.append({"reason":"timestamp_trust_invalid","detail":r.stderr[-300:]}) - except Exception as e:warnings.append({"reason":"timestamp_trust_not_checked","detail":type(e).__name__}) - elif timestamp['present']: - warnings.append({"reason":"timestamp_present_trust_not_checked","detail":"RFC3161 imprint is valid; pass --tsa-ca-file for signer certificate-chain trust validation."}) - - key_identity={ - "computed_key_id":computed_key_id, - "manifest_key_id":manifest_key_id, - "manifest_match":not manifest_key_id or computed_key_id==manifest_key_id, - "expected_key_id":expected_key_id, - "expected_key_match":None if expected_key_id is None else computed_key_id==expected_key_id, - "trusted_public_key_supplied":bool(trusted_public_key), - "trusted_public_key_match":trusted_key_match, - "trust_note":"An embedded public key proves bundle integrity under that key. Pin --expected-key-id or --trusted-public-key when signer identity/authenticity must be established out of band." - } - return {"valid":not failures,"events":len(events),"witnesses":len(witnesses),"disclosures":len(disclosures),"failures":failures,"warnings":warnings,"decision_id":manifest.get('decision_id'),"workspace_id":manifest.get('workspace_id'),"software_version":manifest.get('software_version'),"evidence_profile":manifest.get('version'),"bundle_schema":manifest.get('bundle_schema') or 'legacy','signature_algorithm':manifest.get('signer',{}).get('algorithm') or manifest.get('integrity',{}).get('signature_algorithm'),"key_identity":key_identity,"checkpoint":checkpoint,"timestamp":timestamp,"lifecycle":manifest.get('lifecycle'),"policy_digest":(manifest.get('policy') or {}).get('policy_digest')} - - -def main(): - ap=argparse.ArgumentParser(description='Offline verifier for LoopGrid evidence bundles') - ap.add_argument('bundle') - ap.add_argument('--tsa-ca-file',default=None,help='Trusted CA bundle for RFC3161 signer validation via OpenSSL') - ap.add_argument('--expected-key-id',default=None,help='Pin the expected LoopGrid signer key id, e.g. ed25519:abc123...') - ap.add_argument('--trusted-public-key',default=None,help='Pin signer identity to an out-of-band trusted PEM public key') - args=ap.parse_args();r=verify_bundle(args.bundle,args.tsa_ca_file,args.expected_key_id,args.trusted_public_key) - # Keep CLI status text ASCII-only. On Windows, a verifier launched with stdout - # redirected to a subprocess pipe may inherit a legacy code page that cannot - # encode Unicode check/cross glyphs even though verification itself succeeded. - print('LOOPGRID EVIDENCE VERIFICATION') - print('[OK] VERIFIED' if r['valid'] else '[FAIL] INVALID') - print(json.dumps(r,indent=2)) - raise SystemExit(0 if r['valid'] else 2) - -if __name__=='__main__':main() From 9ec8ca371d1c7256651e1ef95b66c8053d094026 Mon Sep 17 00:00:00 2001 From: Cybertech <131432994+cybertechsoft@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:24:18 +0530 Subject: [PATCH 4/4] Delete verifier/README.md --- verifier/README.md | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 verifier/README.md diff --git a/verifier/README.md b/verifier/README.md deleted file mode 100644 index 5f7af7e..0000000 --- a/verifier/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# Canonical verifier required - -Before publishing this GitHub Action, copy the **canonical current LoopGrid v0.8** -offline verifier into this directory with the exact filename: - -`loopgrid_verify.py` - -Do not use the website's synthetic-demo verifier (`verify_synthetic.py`) here. -The Marketplace action must verify the real LoopGrid evidence-bundle format. - -Expected invocation, based on the current LoopGrid product documentation: - -```bash -python3 verifier/loopgrid_verify.py evidence.zip -``` - -The verifier should exit with code 0 only when verification succeeds and with a -non-zero code when integrity/signature/chain verification fails. - -If the current verifier does not yet use exit codes this way, adapt the wrapper -only after inspecting the real verifier's output contract.