From 6bc4154baecb24dcf901dfc6710d4f6632568287 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 19 Aug 2026 20:24:04 -0400 Subject: [PATCH] Anchor witnessed release manifests in Bitcoin via OpenTimestamps Each releases/manifests/.json already carries dual RFC 3161 receipts and a producer signature over its exact bytes. ots/.json.ots now timestamps those same bytes through OpenTimestamps, so the journal state a manifest commits to has an existence bound outside this repository's control. Proofs live outside releases/ because the append gate keeps that tree closed to anything but exact release bundles, and OpenTimestamps upgrades rewrite proofs in place. scripts/ots_anchor.py stamps missing proofs and upgrades pending ones idempotently; all fifteen current manifests are stamped, with Bitcoin attestations pending until the calendars' aggregate transactions confirm. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 3 + README.md | 8 + ots/0000-307cedbc91de43be.json.ots | Bin 0 -> 630 bytes ots/0001-916626696d034b80.json.ots | Bin 0 -> 595 bytes ots/0002-a69272175b73c83b.json.ots | Bin 0 -> 805 bytes ots/0003-cfae6e9b4524db6d.json.ots | Bin 0 -> 770 bytes ots/0004-36322993cf45b6d1.json.ots | Bin 0 -> 630 bytes ots/0005-9bcc4ff6b3fad5d2.json.ots | Bin 0 -> 700 bytes ots/0006-770683e59da14f45.json.ots | Bin 0 -> 630 bytes ots/0007-2b5ed02908832f0c.json.ots | Bin 0 -> 770 bytes ots/0008-070e797b855dce92.json.ots | Bin 0 -> 630 bytes ots/0009-995768a31dd8fa6d.json.ots | Bin 0 -> 665 bytes ots/0010-6ba8c08f34189164.json.ots | Bin 0 -> 700 bytes ots/0011-34319583df55ce83.json.ots | Bin 0 -> 735 bytes ots/0012-3a5ef7eeee484370.json.ots | Bin 0 -> 735 bytes ots/0013-d47323bbaacda2d1.json.ots | Bin 0 -> 1015 bytes ots/0014-bd12e9e3e79a5529.json.ots | Bin 0 -> 700 bytes ots/README.md | 82 +++++++ scripts/ots_anchor.py | 350 +++++++++++++++++++++++++++++ tests/test_ots_anchor.py | 220 ++++++++++++++++++ 20 files changed, 663 insertions(+) create mode 100644 ots/0000-307cedbc91de43be.json.ots create mode 100644 ots/0001-916626696d034b80.json.ots create mode 100644 ots/0002-a69272175b73c83b.json.ots create mode 100644 ots/0003-cfae6e9b4524db6d.json.ots create mode 100644 ots/0004-36322993cf45b6d1.json.ots create mode 100644 ots/0005-9bcc4ff6b3fad5d2.json.ots create mode 100644 ots/0006-770683e59da14f45.json.ots create mode 100644 ots/0007-2b5ed02908832f0c.json.ots create mode 100644 ots/0008-070e797b855dce92.json.ots create mode 100644 ots/0009-995768a31dd8fa6d.json.ots create mode 100644 ots/0010-6ba8c08f34189164.json.ots create mode 100644 ots/0011-34319583df55ce83.json.ots create mode 100644 ots/0012-3a5ef7eeee484370.json.ots create mode 100644 ots/0013-d47323bbaacda2d1.json.ots create mode 100644 ots/0014-bd12e9e3e79a5529.json.ots create mode 100644 ots/README.md create mode 100644 scripts/ots_anchor.py create mode 100644 tests/test_ots_anchor.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23b7556a..99ee9fd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,6 +132,8 @@ jobs: scripts/canonical_json.py scripts/cut_release_manifest.py scripts/verify_release_chain.py + scripts/ots_anchor.py + tests/test_ots_anchor.py - name: Test Arch surface run: > @@ -157,6 +159,7 @@ jobs: tests/test_policyengine_ledger.py tests/test_release_chain.py tests/test_thesis_append_adversarial.py + tests/test_ots_anchor.py -q - name: Build target input database diff --git a/README.md b/README.md index d65fb107..84620d48 100644 --- a/README.md +++ b/README.md @@ -572,3 +572,11 @@ package with this repository's trust pins committed in `scripts/receipt_pins.py`; see `releases/README.md` (immutable post-genesis, like everything under `releases/`) for the schema, offline verification procedure, and security limits. + +Each release manifest is additionally anchored in Bitcoin through +OpenTimestamps: `ots/.json.ots` timestamps the manifest's exact +bytes, so the anteriority of the journal state it commits to does not +depend on trusting this repository's git history. A scheduled job stamps +new manifests and upgrades pending proofs daily. See `ots/README.md` for +what the proofs do and do not establish and the two-command verification +procedure. diff --git a/ots/0000-307cedbc91de43be.json.ots b/ots/0000-307cedbc91de43be.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..aec5cfeb6b3a582901ea542e542f6b8cba031860 GIT binary patch literal 630 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(3~Ju)nRw56pSY0CfjXgN z>wjG3xBN~{OS^fzH+W~mxA+eNDp4~egfA=q?6~-R#mSG?o^t&EAYh?yC~MQU`Dt)1avy4-<(D!@YoNDRSC6Xcc>)yH^j9i!h74Fnz zn*yHPztNB3PSw+eIW;jSGdY!zV?PMIif?^h6}DgF2Ak*4M_*m80&Ty1;IhEa>0A@m z^oG7+68u=myWrxZuM_`sDLY@jeCN3T54i0I7ffH7@4vhZZo8%i%=Wa*yu4IkyuhL= SwIsDDFSDew92jAs*a84Hup@5( literal 0 HcmV?d00001 diff --git a/ots/0001-916626696d034b80.json.ots b/ots/0001-916626696d034b80.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..c38f62aa60c2d415a3894d02b719602ed54e8b32 GIT binary patch literal 595 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(CZ?%n<}!OX_yoj9@*Lk% z(Ri=SDLtD*p-4tykKfAL2_FPRZ}+_YRh~Ng(2+G7J1hb}aQy!uAi}0~ZsA|sPgZA( ze0P=?TXTG5$!haR`@k{NHCbz7+~UU!&G#Sk>iy*DQ&!3-DJdwn($`NaN!CkF%t_5l zNh|^~N)mH&5-Te8lJj%_f8bD0QfSz(RFTE;K_H}d_d#dn0>;$qyftn=w?2WJ{o`-+ z7hmS;wQ#d_wBcswC+VS>pAYnQ3EbOy`94(_dz*7y`_C1rhdn?ge^fdSras@cWb4#DS6kWZ1!8_wiOQt>!7*JgqG74eZwE1u0>7=9v!TN&bq)DIkyCTXS*&TdPE i`$1C!=7+S*yu4IkyuhL=wIsDDFSDew92jAs*a84%Yzlq= literal 0 HcmV?d00001 diff --git a/ots/0002-a69272175b73c83b.json.ots b/ots/0002-a69272175b73c83b.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..6e4747c7a5e6abce8155c48d14dc0a89d310519e GIT binary patch literal 805 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(mQ5-Wk1jr8Efo52*43|Z zmQtxdH#?rpQF$b}?fv%d(%26IvFn86S>21*@YnYxS6`pO&+-2Qhn7E|vdR;wa~vN8 z!tYO)J#d_P@3VQiAE%Uvb#Q!C@SNXZ`TVxjEFNna$qCAjxtqmq?&eN-DDmXEQBm)< z``VFKy|UJH|Bc7bvYuCYoi|HRNLhyCBTH7BNBRd2owUu0 zU5f4V8Jh1u=GFVj)2E}IQBqP+Y^ATCl%J%RRFbThoS2iEmy%edmk;!73EZ!G`9_rOOI@UED(BzQ{x&MDgV&2(Lc?Tcl-`&XF3RDvE zKIex|Usd=Y-fd~~>ufhQ8N564YNybOb@>s|p(d#naNm^hu*S^PJhuw&8$Df^ZxVAd zlT!)%N1#wHOhKq$ocX}X@^V|;4ku@`k|JKs8FlIx5MA-Kk55xA)JXj0#g!wG)cW!=s zpQ_8&pF3NaJx%oFY!}a8=bEY~NgEiKs6T^vBJBgmd6}yk2Yww2hkHU(1Llde%)GqR zN<@sLmZTQtWtLQyC+6hnCFkdU5csxF$h>Z9?=`ci3-Lxv4ujIdj2nJ8*=^Pd1XMnK z*WNv)XwU3}OYU;qd7Ai2?dAfh#C<^LhiI%bjPDg+_t5qG6va2(+^g?3vgw8gU$$sA q)LGIv?FKx6mqk9?d{*c# z%k7;5G{Qd5(R-p`|FF4YrP>F9NT<7B!&Bt9tl9nA{Qbf>PLBT{IFv$U5>DQJet_eH zg1Xt89UaVi%fD1pO+1{qXbr=h`a2+5d!eEEnMTs8HvTS$OWxnINSDtyR;F z_tdxP=r4_SwK!UTb#mJAr6PPB9|UT;gdWalNKB11@5wmqx!en=E7d-f=wx#XfZ_lA8XX7pnQMyn#FR%eR~Z z*0Py>;;|ebS+d$ZGCpuvWPUJS;q$$jq51w}UcH|@eR{eXB_##LR{HvhIho0+dPyb8 zddZ17sd*`hMSA(bFe`zFnO=TT`u`6Cwg2iopDx?4q>}5~>Obvc8PJ7GH%ry|w=#bB zF-bir*)-GS(R`*?yEjWjEjaa2apUEV%RmZ2kCuP+wwSLd)U2=SR6@R)05RKW%*pU_~Ak1Sbj9+@9FcGR!f zeAeeV7en*?$Gmz!dHVEpGfGMdimmkZ6LT_?Q}vQclJ$}kb5iqC5{vZmfxaz)`&KW% zDE}x{5`KV70(*?u&)!* zj=s0CA|Mm!%=qZp3OaSO=U#hhk+(5)+iw$9>*wLS<~6Q9(CtyFz$^xL=JVshioQ!G S+rpixqYZOrQhpMlKnDPR?;BD8 literal 0 HcmV?d00001 diff --git a/ots/0005-9bcc4ff6b3fad5d2.json.ots b/ots/0005-9bcc4ff6b3fad5d2.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..aba2a7ca3bee676396c064dd945e7c4e35905834 GIT binary patch literal 700 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(W}or@w)xl9OGhqVnqqjP z!q4iJ%|1u22+=1yQuXBj$8P^1P_|S0_aQ&lo_oJ|WsS~W_{Z`81BXpP607d}SW%7- z0-J;uzW#CS(%PQCtG%ABJUO4^gTmrXTE~J-0P4P~9?r zr`^{*K+Eq$Ji42*&BXWcgqE6BM_c4Xyz-(ur6YK+xBQxSXYD};pb~le0^v`5Ii$wUE4?wpa$>MXldPSxo-ojg1UOm0}aari1RgH3{74wfjp6EZf j3vTgEw)7=|+msL%D=Wb)Mv3Cf!=i4{PH`0y2$~wE)bv5%sB?>B8{?swSEch618eLjHxl!tQXJC=J=>kl-PRRk#GKM8RKt0zHixItoB;^Vb7GJvro@lQab3y ztibV+C9BON`vZsR79-1O*@8}n=KGI%^?vg7>1bz^loS+O>FX!uC+Q`XB$?>J!$oPW)%N+gNoY{nVCS#bE?ermn7HtRL{To)qBhN zqDhi-*(&nY@+KwtyX}7N^9*je>jjky-HB?>aLY9{V3wz4=H;bUBJ55rNiE9DEU7F{ z%*oM9&d&wfyeE#AH`J$2uO}hyVs~^@B+%wHZ~iqono9pum%HFpX^`#nU1t9L_9@j5 zs+qW2t@c^ph1<-})_8JV!+LYL&B{tJn^C-+oLB<1FtGyYlKk8c97l7E>SsxRUjq#E z^}ib93po?pbs09aXU*_CJVd( literal 0 HcmV?d00001 diff --git a/ots/0007-2b5ed02908832f0c.json.ots b/ots/0007-2b5ed02908832f0c.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..176a3b3147f441457dac020c85b42c6d2d4a4a79 GIT binary patch literal 770 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(wBs&lay0AnnBLyAZi0sJ zhc(Wnj8iX|g+DoXyKQc$^~4VXuD4XJf9uR%J$PnKxw1cg`1y|N2-(EQsTS0{e;M&l-GFk8XT+<+Wjd&(wysrQ3szwzZwR!pUI0 zMN|W*WJ#aYw0qMJ$kudSTA6k1$mbhf>sjg!yvsN_WB1;RLK{;#KC)!BdE|WHkQR8( zJ)!H@FNWs(k9qZe^7QFwXOxr_6kF-*C*>#UC6y%WB`4;j=A|SS>E#3cS_1d0UVc$J z(5Y?0OjdDz9Gien-Pl_6#`QwU8BGDv5RWyFZ*Y86ShIJ3PQ!`wN>&y3jc*M;_Iq#+=*WkX<(}npa zF()%Qm9T#VW*F^vy_u-elaZS^z{~Cp+W45W+rJS}Ngu8Id?!Gg3 zg%4-KU8t-Cb0JDtCnuI9=Hw(+RO%(?=Y9~_uqWFwvG|mFP-w$1wNo|mKr0W}F>8r9 zw_Gk}*%5i;R!h~MPiu4U?_^zh-EK{3efx$dz|fD|xpzU8?#yrbvrm<-KHb{9Qlaw7 z?g=dS_U>PwoZtGy4(_V|f4t6ZY|g#}ca^3F%vEWbd3mWoSHKcMYDsEQUS>&UIna3^ Gw*dhEqhXc+ literal 0 HcmV?d00001 diff --git a/ots/0008-070e797b855dce92.json.ots b/ots/0008-070e797b855dce92.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..1156f28f0bda689162a85d9ffe63d6bf2f4951bd GIT binary patch literal 630 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(*!e1}TVu~nVw>rAV^QX` zq?uhjds#1Z*Kpoy`dvM1M&$>Ax38bYB;23A<1W{_zIBGyp&b7|2)N`*cP+l0TD-;2 zTmEqPyhx6ZELm+HxgR*vdzO`Nt^F&((0ul4QN)#GKT; zl*A$+qa-mWC$XYZFF8LKXkhRHzJ(1*S9~UJ4Y~MAno)@3qeANO8ULNLg+&##-|tRt zUXpON_R=Gpdyke1%y{?ft@ZUIa67rDec7k3wTKmNr=|wX&a}+Dyi}kaFl$pwQj793 zODfBOmP2gj=)952;*=XK!|_3&S9cGuPFq^~9qohzx2FG`3v`4;;UC?PdU3qBbt<%X zT-tl(>9z;+v%VIYb$X-)a0Uw*a(qzWTxqqArTxYH<3Kq zJ_c<*kp_3?{(pYA)D)c!;qKJag}E~^Co?%!4<&^1fe}{%k2t;jqVx|O&o=y5YfE|h z66mmEAKO`OZG1n>BJa*)c&~Z=+oLEa$3t3I#Z{FTN+s2L0lgzS>*W7EOE#}HdS|-h zpYPw&Zxc{Ee3&>cyiBAO*9lKsxZrTddfi{UeJ5Pq?YRJ`M7pEylI1q#xE#d| zx2--3OkEv;plyt^aE&Ud16kEUUGi!2Z8(h yBzFF_`niTtsO-veg?X}YQ>$x)En}7)?uMJHtOPR^C6bd9OMr$YRse0u&jkPm;3oh8 literal 0 HcmV?d00001 diff --git a/ots/0010-6ba8c08f34189164.json.ots b/ots/0010-6ba8c08f34189164.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..00bc5b51fe0af7d3f7179c5a29e8632191b3ee16 GIT binary patch literal 700 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(vR54FH<6f_l0K)k?r~|% zzAXv|r5sfk6m+MpI3DBQS@c1`*>5hB?}A$lPb~IV>7sC>`k3-ZEK- zm9Jz!0-d^tC+SA(Lh*a5<`ee1^sw>(o%%D-H~Z55)&};*OG?oU8r9E_|7={n*XMs` zsm(EmBeA=IPCecwsPN{`ufTwPZ%abvc-Lxsn0Frs44~BF4}tN n{2p_-19P7w2pp`^PJla5SqbJqlyFQ=ECEJ!Vg)b_rkQ literal 0 HcmV?d00001 diff --git a/ots/0011-34319583df55ce83.json.ots b/ots/0011-34319583df55ce83.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..f765d6f5c813fab26a138ee0cdc150b97c13ea78 GIT binary patch literal 735 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(Obn+s-w!?4Ty%1kSoPmi z`J3*0d6+U}9=VnGhb?-g%cKti397A8OOkk>FfPCRl&`l;mgD~ij%*%HU7ZHi4ICc? zYHjKc-r?(+J_@&N^=`&d{ydy`}mr( z)oh@Wdu^wW-K;Fy|29p9Z~Bvy(s$-Ryc(GwTjYJ>@$;aQ+Ke0@S+d$Z3O;b`RY>ei z6?@IX(0u{3N}kl4QN)#GKT;l*A&ve4t-T;C|K1FG~Oa zL150S?3pJ@TUD3rxLGQ{xa1VaM}?~u+2J4OoDIoo;Ly9#ae3ujsmM&b>zDX@pGD}J z7TWp)-FN5J$t7}Un$CAvIP|zZyXEpMRhr56%gH^;liwe;QJ-c9ci)!_OBz)&v3bsDCHriNsoNx!2A>uk5wa?x>_5NTjv^}xiFmLLY za|dMe&ad*mGqK|;+$%4uWv{R2Jz@{{ik>daD~UOo$*F__`-8vR1;+=0slR(wq`a~h?$cX)T~a*E z5$K7-3nQjC>2LnQ8M^W2A)X(5I?shZ^uF?Txy63({1r8e&%#}J()>|LkIWZ#xC?c( z;V#Ti(nE1&J}?SO;8CELUz83E!Ntp>8Uq7`62Hye=3wh^Pyy)V^|`foS4~$4<=%d1 zg3Jd4+g}qdnBH9%U!}Q6+{Pnh0u#`E2~Q5qiai&2B9XCTe}98ZTv4xs?5kbb*{sWR zUEUP-?SZ>*m9c-n(YGgJaQA6yz}%OXnU|Le3^7Ii8y0|MIXU(3zPAcHFZJPCa!f+ZevsNM(ZbCf^T_e?I$F z|G;4G>iMleHz@Ei$xe+p;P~dLa2&h-(i?59{k+{A6Y3@_JH)fx-gsXJ?###+Uz>b( VRn3MwQ%@J>%*33``2{P2&In literal 0 HcmV?d00001 diff --git a/ots/0013-d47323bbaacda2d1.json.ots b/ots/0013-d47323bbaacda2d1.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..ef93c10261fe2400827ec49fc609b8d63ad0d787 GIT binary patch literal 1015 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(t`sZpUUhcS#mQawj@Y=b zUYlRoW^$$On8>?>J6S#F3%~pzFi}rDy!x4&K=SSVJ56Tk@p1hBAi((8V`IN+5vNwv zyY!>VK?WQj6-3qac1z1%(zuzrU4cH5SzW)Np&BtJh7g z*#4zAZvGIp77zm}`DwQ0wQH{a)hlU7EIF&q7pGtU*i~&M693=jh`3Ol|7MPlELm+H zMISg8WKKTCmsc#r(0uX!{4{d09aBn-*Vsnkx8!!E^oMskVP-RZHJ# zm0uaL(`eSVx~lK(*1kZ;-II0w;e7h{vE#m5{~r-N*O8X7JvMd4mURbz>iB!?kv|Sp z;wE?L$qA#`n@?wNQaJiQd*h;xkI~s(D_wbVcDbJZ{t0Btt`Y3#c-jwL>g zy;U&xe~2GvfDM%(Ip5ueS6Tgz? zkQs-JC(pLf&rESD2L^>uo1Ich)e_5-+3ANJ_9jhjei$gk#`fjSt0_{C(|#^o3pBW8 z>GN(Y*L_a&X03eJv-(Yn^|dMLobN+ZSu9tX{qDQ70H|cfVXwR4(HHOCsBGfrUXgOK zKvCU@VOD3r%7k2vN7H|~!u@$Db;Y~+0%eoo{?yZj`7<#mGdWcc#kcvuG+zQw^LqJ3 z=^r@edpIuaI~BPNXzN;rg#T>&thDBbwps0%e}Bw643R;nA4wp7$O^En^xA5}H_o~xQS0}BRk=t>MuV+_U_Ps7v3dro4mI2!Qb+hi@u@(90fPx4>JRme7C}q= literal 0 HcmV?d00001 diff --git a/ots/0014-bd12e9e3e79a5529.json.ots b/ots/0014-bd12e9e3e79a5529.json.ots new file mode 100644 index 0000000000000000000000000000000000000000..3d6afc193337bc05d7b796db88070cd929726a87 GIT binary patch literal 700 zcmZSZFG$S`$;?eHE=kNSC}v;?D9X=IW7yyM=tawmNmCd(_6ogx{CrlZW(J#!A-n0t zM8}N}w~DfmOR~wbEo?SIet^5c!xZ!14cs0MDfyoA7;EZEgGW z=P;i4n8NWvVX5>fgA<7<1sqPH#p1gjZ$GwoI+{}zsqELm+H#UD7{ zDLyu=UH8MCq51w}UcH|@eacE1B_##LR{Hu$CCPfpi8-lxDTzfuMoD5$PGUu+UUGi! z{|_A6DLFI#Dj!zm_^8lYn)s#V=Gq-OLd$CxeKIj={BJjjFH-GK(QgZevVGh3aeNRk z{uvZ`$dhs3`3cFtHr;D80J<<+U)nKI?CY1hb8?4OHyVGrFJ(}5wcPDp!qKe>qK0i- zfd+pIQ&^Jk)~fQ$#?~fQV@ifoC5zFl&u`A=?7Fb-?TvQ}fTrAjaHWX-us{s+{uPP^ z^W4v#^IauT%KmfVW|n$izd6eu!Tnjw>8qCVVc%D{KlOBB{!GltOitB9@ohdZo=V{H zq?ccm4)k)bfkY8MZ*4WuKlO)~oJ_CfTbh}5ZDCZ;wR=F9T)MgIVrrU>=&47%XZJ2< z>@QB3sptCcIp3$;OZF;1b6nvriFSYUuT18XB-|xB+AxNy#{7xXSJ z+0TFb$T9vnU_i0R*-AF=lABs!n$wx^rgxs2x6h#o$KOdx=e!Gf^P+MB-0td0zutLH l%Dn-%TT=sOcUop%UMetlVG*8Ml3J9PSyEXJjCN3*0|2m2IspIx literal 0 HcmV?d00001 diff --git a/ots/README.md b/ots/README.md new file mode 100644 index 00000000..0cb9a7e3 --- /dev/null +++ b/ots/README.md @@ -0,0 +1,82 @@ +# Bitcoin-anchored checkpoints (OpenTimestamps) + +Every witnessed release manifest in `releases/manifests/` has a sibling proof +here: `ots/.json.ots` is an [OpenTimestamps](https://opentimestamps.org) +timestamp over the exact bytes of `releases/manifests/.json` — the same +bytes the two RFC 3161 authorities witness and the pinned producer key signs. + +## What this adds + +`releases/README.md` states the limit of the existing scheme: internal +verification proves a clone is self-consistent, but cannot by itself +distinguish the original history from a complete, freshly witnessed +replacement fork, so verifiers must retain an external checkpoint. These +proofs are that checkpoint, kept in a system the operator does not control. + +A completed proof carries a Bitcoin block attestation: the manifest bytes +existed no later than that block's time. Because each manifest commits to the +full journal bytes (`state.jsonlSha256`, `state.lineCount`), the immutable +prefix (`state.immutablePrefixSha256`), and the previous manifest +(`previousManifestSha256`), one attestation bounds the existence time of the +whole journal state and manifest chain it commits to. A rewritten history +would need its own anchors, and Bitcoin will only ever attest the time the +replacement was actually made — backdating is not available to anyone, +including us. + +The proofs do not add uniqueness: like the RFC 3161 receipts, they cannot +prove that no parallel fork exists, when GitHub accepted a proposal, or that +a manifest's claims are true. They move the anteriority bound outside the +operator's git history; the other caveats in `releases/README.md` stand. + +Anchoring began 2026-08-19. Every release manifest that existed then, +releases 0000 through 0014, was stamped that day, so their Bitcoin bounds +start there; the RFC 3161 receipt times remain the earlier per-release +witnesses. Later manifests are stamped by the scheduled job soon after they +land. + +## Verify + +From a clone, first re-verify the witnessed chain (this recomputes the +journal digest that the head manifest commits to), then check any release's +proof against Bitcoin: + +```console +python3 scripts/verify_release_chain.py --full +ots verify -f releases/manifests/.json ots/.json.ots +``` + +The `ots` command is the OpenTimestamps client +(`pip install opentimestamps-client`, or run it as +`uvx --from opentimestamps-client ots`). Full verification checks the +attested block header against a local Bitcoin node. Without a node, run + +```console +ots --no-bitcoin verify -f releases/manifests/.json ots/.json.ots +``` + +which validates that the proof commits to the file's exact bytes and prints +the Bitcoin block height and merkle root to check against any block source +you trust. A proof whose attestation has not yet been aggregated into +Bitcoin reports `Pending confirmation in Bitcoin blockchain`; pending proofs +are upgraded in place by the scheduled job once the calendar's aggregate +transaction confirms. + +To sweep every proof in the repository at once: + +```console +python3 scripts/ots_anchor.py verify +``` + +This fails on any digest mismatch or missing proof, and with +`--require-bitcoin` also fails while any attestation is still pending. + +## Operations + +A scheduled GitHub Actions job (workflow on the default branch) checks out +the journal branch and runs `python scripts/ots_anchor.py run` daily: it +stamps any manifest that lacks a proof, tries to upgrade pending proofs, and +commits the result. The run is idempotent. Stamping happens on a temporary +copy so nothing is ever written under `releases/`, which the append gate +keeps closed to anything but exact release bundles. Once a proof carries a +Bitcoin attestation it is left untouched so its committed bytes stay stable; +`git log` retains every earlier pending version. diff --git a/scripts/ots_anchor.py b/scripts/ots_anchor.py new file mode 100644 index 00000000..ee733726 --- /dev/null +++ b/scripts/ots_anchor.py @@ -0,0 +1,350 @@ +#!/usr/bin/env python3 +"""Anchor witnessed release manifests in Bitcoin via OpenTimestamps. + +Each release manifest under ``releases/manifests/.json`` is already +witnessed by two RFC 3161 authorities and a pinned producer signature over its +exact bytes. This tool adds an operator-independent witness: an OpenTimestamps +proof over those same exact bytes, committed as ``ots/.json.ots``. +Because manifest ``state.jsonlSha256`` covers the full journal bytes and +``previousManifestSha256`` chains every earlier manifest, a Bitcoin +attestation over one manifest bounds the existence time of the whole journal +state it commits to. + +Proofs live in the top-level ``ots/`` directory, never under ``releases/``: +the append gate keeps ``releases/`` closed to anything but exact release +bundles, and OpenTimestamps upgrades rewrite proof files in place, which the +release-history immutability check would reject. + +Subcommands: + +- ``run``: stamp any manifest that lacks a proof, then try to upgrade pending + proofs to complete Bitcoin attestations. Idempotent; safe on a schedule. +- ``verify``: check every proof against its manifest's current bytes and + report attestation status. Exits nonzero on digest mismatch or a manifest + with no proof. +- ``status``: list proofs and whether each is pending or Bitcoin-complete. + +Requires the ``ots`` CLI (PyPI ``opentimestamps-client``); stamping and +upgrading contact public calendar servers, verification of a complete proof +against Bitcoin needs a local node or the printed manual block check. +""" + +from __future__ import annotations + +import argparse +import hashlib +import pathlib +import re +import shlex +import shutil +import subprocess +import sys +import tempfile + +ROOT = pathlib.Path(__file__).resolve().parents[1] +MANIFEST_DIR = pathlib.Path("releases/manifests") +OTS_DIR = pathlib.Path("ots") +MANIFEST_NAME_RE = re.compile(r"^(\d{4})-([0-9a-f]{16})\.json$") +SUBPROCESS_TIMEOUT = 300 + +# Stable output substrings observed from opentimestamps-client 0.7.2. +_MISMATCH_TEXT = "File does not match original" +_PENDING_TEXT = "Pending confirmation in Bitcoin blockchain" +_MANUAL_TEXT = "To verify manually, check that Bitcoin block" +_NO_NODE_TEXT = "Could not connect to Bitcoin node" +_UPGRADE_PENDING_TEXT = "Timestamp not complete" +_BITCOIN_ATTESTATION_TEXT = "BitcoinBlockHeaderAttestation" +_PENDING_ATTESTATION_TEXT = "PendingAttestation" + + +class AnchorError(RuntimeError): + """A condition that must stop the anchoring run.""" + + +def sha256_file(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for block in iter(lambda: handle.read(65536), b""): + digest.update(block) + return digest.hexdigest() + + +def discover_manifests(root: pathlib.Path) -> list[pathlib.Path]: + directory = root / MANIFEST_DIR + if not directory.is_dir(): + raise AnchorError(f"manifest directory missing: {directory}") + manifests = sorted( + path + for path in directory.iterdir() + if path.is_file() and MANIFEST_NAME_RE.match(path.name) + ) + if not manifests: + raise AnchorError(f"no release manifests found in {directory}") + return manifests + + +def check_manifest_name_digest(manifest: pathlib.Path) -> str: + """Refuse to anchor bytes that contradict the manifest's own filename.""" + + match = MANIFEST_NAME_RE.match(manifest.name) + if match is None: # discover_manifests already filtered on the pattern + raise AnchorError(f"unexpected manifest filename: {manifest.name}") + digest = sha256_file(manifest) + if digest[:16] != match.group(2): + raise AnchorError( + f"manifest {manifest.name} bytes hash to {digest[:16]}..., " + "which contradicts the filename; refusing to anchor" + ) + return digest + + +def proof_path(root: pathlib.Path, manifest: pathlib.Path) -> pathlib.Path: + return root / OTS_DIR / f"{manifest.name}.ots" + + +def _run_ots( + ots_bin: list[str], arguments: list[str], *, timeout: int = SUBPROCESS_TIMEOUT +) -> subprocess.CompletedProcess[str]: + command = [*ots_bin, *arguments] + try: + return subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError as exc: + raise AnchorError( + f"ots binary not found ({command[0]!r}); install " + "opentimestamps-client or pass --ots-bin" + ) from exc + except subprocess.TimeoutExpired as exc: + raise AnchorError(f"ots timed out: {' '.join(command)}") from exc + + +def stamp_manifest( + root: pathlib.Path, manifest: pathlib.Path, ots_bin: list[str] +) -> pathlib.Path: + """Stamp a temporary copy so nothing is ever written under releases/.""" + + destination = proof_path(root, manifest) + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="ots-anchor-") as name: + working_copy = pathlib.Path(name) / manifest.name + shutil.copyfile(manifest, working_copy) + completed = _run_ots(ots_bin, ["stamp", str(working_copy)]) + produced = working_copy.with_name(working_copy.name + ".ots") + if completed.returncode != 0 or not produced.is_file(): + raise AnchorError( + f"ots stamp failed for {manifest.name}: " + f"{completed.stderr.strip() or completed.stdout.strip()}" + ) + shutil.move(str(produced), destination) + return destination + + +def proof_is_complete(proof: pathlib.Path, ots_bin: list[str]) -> bool: + """True when the local proof file carries a Bitcoin attestation. + + ``ots info`` reads only the proof file, no network. An upgraded proof can + still list leftover ``PendingAttestation`` entries from calendars that + have not answered; one Bitcoin block attestation is what matters, and a + complete proof is left untouched so its committed bytes stay stable. + """ + + completed = _run_ots(ots_bin, ["info", str(proof)]) + output = completed.stdout + completed.stderr + if completed.returncode != 0: + raise AnchorError(f"ots info failed for {proof.name}: {output.strip()}") + if _BITCOIN_ATTESTATION_TEXT in output: + return True + if _PENDING_ATTESTATION_TEXT in output: + return False + raise AnchorError( + f"proof {proof.name} lists neither a Bitcoin nor a pending " + "attestation; refusing to guess" + ) + + +def upgrade_proof(proof: pathlib.Path, ots_bin: list[str]) -> None: + """Try to fold a Bitcoin attestation into a pending proof. + + Only called on proofs classified as pending. A still-pending calendar + answer is normal; anything else nonzero is a real failure. The client + leaves a ``.bak`` beside an upgraded proof; git history already keeps + the pending version, so the backup is removed. + """ + + completed = _run_ots(ots_bin, ["upgrade", str(proof)]) + output = completed.stdout + completed.stderr + backup = proof.with_name(proof.name + ".bak") + if backup.is_file(): + backup.unlink() + if completed.returncode == 0: + return + if _UPGRADE_PENDING_TEXT in output or _PENDING_TEXT in output: + return + raise AnchorError(f"ots upgrade failed for {proof.name}: {output.strip()}") + + +def classify_proof( + manifest: pathlib.Path, proof: pathlib.Path, ots_bin: list[str] +) -> str: + """Classify a proof against the manifest's current bytes. + + Returns one of ``"bitcoin"`` (complete attestation, verified or reported + for manual block check), ``"pending"`` (bound to these bytes, calendar + attestation not yet in Bitcoin), or ``"mismatch"``. + """ + + completed = _run_ots( + ots_bin, ["--no-bitcoin", "verify", "-f", str(manifest), str(proof)] + ) + output = completed.stdout + completed.stderr + if _MISMATCH_TEXT in output: + return "mismatch" + if completed.returncode == 0 or _MANUAL_TEXT in output: + return "bitcoin" + if _PENDING_TEXT in output: + return "pending" + if _NO_NODE_TEXT in output: + return "bitcoin" + raise AnchorError( + f"unrecognized ots verify outcome for {proof.name}: {output.strip()}" + ) + + +def command_run(root: pathlib.Path, ots_bin: list[str]) -> int: + manifests = discover_manifests(root) + stamped: list[str] = [] + upgraded: list[str] = [] + pending: list[str] = [] + for manifest in manifests: + check_manifest_name_digest(manifest) + proof = proof_path(root, manifest) + if not proof.is_file(): + stamp_manifest(root, manifest, ots_bin) + stamped.append(manifest.name) + pending.append(manifest.name) + continue + if proof_is_complete(proof, ots_bin): + continue + upgrade_proof(proof, ots_bin) + if proof_is_complete(proof, ots_bin): + upgraded.append(manifest.name) + else: + pending.append(manifest.name) + print( + f"ots anchor run: {len(manifests)} manifests, " + f"stamped {len(stamped)}, upgraded {len(upgraded)}, " + f"still pending {len(pending)}" + ) + for name in stamped: + print(f" stamped {name}") + for name in upgraded: + print(f" upgraded {name}") + return 0 + + +def command_verify( + root: pathlib.Path, ots_bin: list[str], *, require_bitcoin: bool +) -> int: + manifests = discover_manifests(root) + failures: list[str] = [] + pending_count = 0 + bitcoin_count = 0 + for manifest in manifests: + check_manifest_name_digest(manifest) + proof = proof_path(root, manifest) + if not proof.is_file(): + failures.append(f"{manifest.name}: no OpenTimestamps proof") + continue + state = classify_proof(manifest, proof, ots_bin) + if state == "mismatch": + failures.append(f"{manifest.name}: proof does not match manifest bytes") + elif state == "pending": + pending_count += 1 + if require_bitcoin: + failures.append(f"{manifest.name}: attestation not yet in Bitcoin") + else: + bitcoin_count += 1 + print( + f"ots anchor verify: {len(manifests)} manifests, " + f"{bitcoin_count} with Bitcoin attestations, {pending_count} pending" + ) + for failure in failures: + print(f" FAIL {failure}", file=sys.stderr) + if failures: + return 1 + print("every release manifest has an OpenTimestamps proof bound to its exact bytes") + return 0 + + +def command_status(root: pathlib.Path, ots_bin: list[str]) -> int: + manifests = discover_manifests(root) + for manifest in manifests: + proof = proof_path(root, manifest) + if not proof.is_file(): + print(f"{manifest.name}: unanchored") + continue + state = classify_proof(manifest, proof, ots_bin) + label = { + "bitcoin": "bitcoin attestation", + "pending": "pending calendar attestation", + "mismatch": "MISMATCH", + }[state] + print(f"{manifest.name}: {label}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="anchor witnessed release manifests via OpenTimestamps" + ) + common = argparse.ArgumentParser(add_help=False) + common.add_argument( + "--root", + type=pathlib.Path, + default=ROOT, + help=argparse.SUPPRESS, + ) + common.add_argument( + "--ots-bin", + default="ots", + help="ots invocation, shell-split (default: %(default)s)", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + subparsers.add_parser( + "run", parents=[common], help="stamp missing proofs, upgrade pending" + ) + verify_parser = subparsers.add_parser( + "verify", + parents=[common], + help="check every proof against current manifest bytes", + ) + verify_parser.add_argument( + "--require-bitcoin", + action="store_true", + help="fail while any attestation is still pending", + ) + subparsers.add_parser( + "status", parents=[common], help="list proofs and their state" + ) + args = parser.parse_args(argv) + + root = args.root.resolve() + ots_bin = shlex.split(args.ots_bin) + try: + if args.command == "run": + return command_run(root, ots_bin) + if args.command == "verify": + return command_verify(root, ots_bin, require_bitcoin=args.require_bitcoin) + return command_status(root, ots_bin) + except AnchorError as exc: + print(f"ots anchor failed: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_ots_anchor.py b/tests/test_ots_anchor.py new file mode 100644 index 00000000..d55da061 --- /dev/null +++ b/tests/test_ots_anchor.py @@ -0,0 +1,220 @@ +"""Tests for scripts/ots_anchor.py. + +The suite never contacts calendar servers or Bitcoin: a fake ``ots`` +executable reproduces the observed opentimestamps-client 0.7.2 output +contract (stamp/upgrade/info/verify), and every invocation is logged so the +tests can assert which operations ran. +""" + +from __future__ import annotations + +import hashlib +import json +import shlex +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + +sys.path.insert(0, str(ROOT / "scripts")) + +import ots_anchor # noqa: E402 + +FAKE_OTS = r""" +import hashlib +import json +import os +import pathlib +import sys + +LOG = pathlib.Path(os.environ["FAKE_OTS_LOG"]) + + +def log(entry): + with LOG.open("a", encoding="utf-8") as handle: + handle.write(entry + "\n") + + +def read_proof(path): + return json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + + +def write_proof(path, payload): + pathlib.Path(path).write_text(json.dumps(payload), encoding="utf-8") + + +def main(): + arguments = [a for a in sys.argv[1:] if a != "--no-bitcoin"] + command = arguments[0] + log(command) + if command == "stamp": + target = pathlib.Path(arguments[1]) + digest = hashlib.sha256(target.read_bytes()).hexdigest() + write_proof( + str(target) + ".ots", {"digest": digest, "state": "pending"} + ) + print("Submitting to remote calendar https://fake.calendar") + return 0 + if command == "info": + proof = read_proof(arguments[1]) + if proof["state"] == "bitcoin": + print("verify BitcoinBlockHeaderAttestation(963213)") + else: + print("verify PendingAttestation('https://fake.calendar')") + return 0 + if command == "upgrade": + path = pathlib.Path(arguments[1]) + proof = read_proof(path) + if os.environ.get("FAKE_OTS_UPGRADE") == "success": + proof["state"] = "bitcoin" + write_proof(path, proof) + pathlib.Path(str(path) + ".bak").write_text( + "backup", encoding="utf-8" + ) + print("Success! Timestamp complete") + return 0 + print("Failed! Timestamp not complete") + return 1 + if command == "verify": + target = pathlib.Path(arguments[arguments.index("-f") + 1]) + proof = read_proof(arguments[-1]) + digest = hashlib.sha256(target.read_bytes()).hexdigest() + if digest != proof["digest"]: + print("File does not match original!") + return 1 + if proof["state"] == "bitcoin": + print( + "To verify manually, check that Bitcoin block 963213 " + "has merkleroot aa" + ) + return 1 + print("Pending confirmation in Bitcoin blockchain") + return 1 + raise SystemExit(f"unexpected fake ots command: {command}") + + +if __name__ == "__main__": + raise SystemExit(main()) +""" + + +def make_manifest(directory: Path, index: int, payload: bytes) -> Path: + digest = hashlib.sha256(payload).hexdigest() + path = directory / f"{index:04d}-{digest[:16]}.json" + path.write_bytes(payload) + return path + + +@pytest.fixture +def repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + manifest_dir = tmp_path / "releases" / "manifests" + manifest_dir.mkdir(parents=True) + manifests = [ + make_manifest(manifest_dir, 0, b'{"releaseIndex": 0}\n'), + make_manifest(manifest_dir, 1, b'{"releaseIndex": 1}\n'), + ] + fake = tmp_path / "fake_ots.py" + fake.write_text(FAKE_OTS, encoding="utf-8") + log = tmp_path / "ots-invocations.log" + log.touch() + monkeypatch.setenv("FAKE_OTS_LOG", str(log)) + monkeypatch.setenv("FAKE_OTS_UPGRADE", "pending") + ots_bin = f"{shlex.quote(sys.executable)} {shlex.quote(str(fake))}" + return { + "root": tmp_path, + "manifest_dir": manifest_dir, + "manifests": manifests, + "ots_bin": ots_bin, + "log": log, + } + + +def run_cli(repo: dict, *arguments: str) -> int: + return ots_anchor.main( + [*arguments, "--root", str(repo["root"]), "--ots-bin", repo["ots_bin"]] + ) + + +def logged_commands(repo: dict) -> list[str]: + return repo["log"].read_text(encoding="utf-8").split() + + +def test_run_stamps_every_manifest_into_ots_dir(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + proofs = sorted((repo["root"] / "ots").iterdir()) + assert [p.name for p in proofs] == [m.name + ".ots" for m in repo["manifests"]] + for manifest, proof in zip(repo["manifests"], proofs): + payload = json.loads(proof.read_text(encoding="utf-8")) + assert payload["digest"] == hashlib.sha256(manifest.read_bytes()).hexdigest() + + +def test_run_never_writes_into_releases(repo: dict) -> None: + before = sorted(p.name for p in repo["manifest_dir"].iterdir()) + assert run_cli(repo, "run") == 0 + after = sorted(p.name for p in repo["manifest_dir"].iterdir()) + assert before == after + + +def test_run_is_idempotent_and_upgrades_pending_proofs( + repo: dict, monkeypatch: pytest.MonkeyPatch +) -> None: + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("stamp") == 2 + + # Second run: calendars still pending — no new stamps, upgrade attempted. + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("stamp") == 2 + assert logged_commands(repo).count("upgrade") == 2 + + # Third run: attestations land — proofs upgraded in place, .bak removed. + monkeypatch.setenv("FAKE_OTS_UPGRADE", "success") + assert run_cli(repo, "run") == 0 + for manifest in repo["manifests"]: + proof = repo["root"] / "ots" / f"{manifest.name}.ots" + assert json.loads(proof.read_text(encoding="utf-8"))["state"] == ("bitcoin") + assert not proof.with_name(proof.name + ".bak").exists() + + # Fourth run: complete proofs are left untouched (no further upgrades). + upgrades_before = logged_commands(repo).count("upgrade") + assert run_cli(repo, "run") == 0 + assert logged_commands(repo).count("upgrade") == upgrades_before + + +def test_run_refuses_manifest_contradicting_its_filename(repo: dict) -> None: + rogue = repo["manifest_dir"] / f"0002-{'0' * 16}.json" + rogue.write_bytes(b'{"releaseIndex": 2}\n') + assert run_cli(repo, "run") == 1 + + +def test_verify_passes_with_pending_proofs_by_default(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + assert run_cli(repo, "verify") == 0 + assert run_cli(repo, "verify", "--require-bitcoin") == 1 + + +def test_verify_fails_on_missing_proof(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + (repo["root"] / "ots" / f"{repo['manifests'][0].name}.ots").unlink() + assert run_cli(repo, "verify") == 1 + + +def test_verify_fails_when_proof_binds_different_bytes(repo: dict) -> None: + assert run_cli(repo, "run") == 0 + proof = repo["root"] / "ots" / f"{repo['manifests'][1].name}.ots" + payload = json.loads(proof.read_text(encoding="utf-8")) + payload["digest"] = "ab" * 32 + proof.write_text(json.dumps(payload), encoding="utf-8") + assert run_cli(repo, "verify") == 1 + + +def test_status_reports_each_state(repo: dict, capsys) -> None: + assert run_cli(repo, "status") == 0 + output = capsys.readouterr().out + assert output.count("unanchored") == 2 + + assert run_cli(repo, "run") == 0 + assert run_cli(repo, "status") == 0 + output = capsys.readouterr().out + assert output.count("pending calendar attestation") == 2