Compare commits
101 Commits
147 changed files with 24672 additions and 5643 deletions
@ -0,0 +1,105 @@
|
||||
name: Build release workflow |
||||
|
||||
on: |
||||
push: |
||||
tags: |
||||
- '*' |
||||
|
||||
jobs: |
||||
create_release: |
||||
runs-on: ubuntu-22.04 |
||||
name: Create github release |
||||
permissions: |
||||
contents: write |
||||
outputs: |
||||
upload_url: > |
||||
${{ steps.get_release.outputs.upload_url || |
||||
steps.create_release.outputs.upload_url }} |
||||
steps: |
||||
- name: Try to Get Release |
||||
id: get_release |
||||
uses: zebra-lucky/actions-js-getRelease@0.0.3 |
||||
env: |
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
with: |
||||
tag: ${{ github.ref }} |
||||
- name: Create Release |
||||
id: create_release |
||||
if: ${{ !steps.get_release.outputs.upload_url }} |
||||
uses: actions/create-release@v1 |
||||
env: |
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
with: |
||||
tag_name: ${{ github.ref }} |
||||
release_name: ${{ github.ref }} |
||||
draft: false |
||||
prerelease: false |
||||
|
||||
build_with_pyinstaller: |
||||
needs: create_release |
||||
runs-on: ubuntu-22.04 |
||||
name: create PyInstaller build |
||||
permissions: |
||||
contents: write |
||||
steps: |
||||
- name: Set up Python 3.12 |
||||
uses: actions/setup-python@v5 |
||||
with: |
||||
python-version: '3.12' |
||||
- name: Set outputs for pkg_ver |
||||
id: set_vars |
||||
run: | |
||||
export JM_VERSION=${{ github.ref_name }} |
||||
echo "::set-output name=pkg_ver::$(echo $JM_VERSION)" |
||||
- name: Checkout |
||||
uses: actions/checkout@v1 |
||||
- name: build |
||||
env: |
||||
JM_VERSION: ${{ github.ref_name }} |
||||
run: | |
||||
./contrib/build-linux/pyinstaller-build/build.sh |
||||
- name: Upload Asset |
||||
uses: actions/upload-release-asset@v1 |
||||
env: |
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
with: |
||||
upload_url: ${{ needs.create_release.outputs.upload_url }} |
||||
asset_path: dist/joinmarket-clientserver-${{ github.ref_name}}.tgz |
||||
asset_name: joinmarket-clientserver-${{ github.ref_name}}.tgz |
||||
asset_content_type: application/octet-stream |
||||
- name: Upload Qt Asset |
||||
uses: actions/upload-release-asset@v1 |
||||
env: |
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
with: |
||||
upload_url: ${{ needs.create_release.outputs.upload_url }} |
||||
asset_path: dist/joinmarket-clientserver-qt-${{ github.ref_name}}.tgz |
||||
asset_name: joinmarket-clientserver-qt-${{ github.ref_name}}.tgz |
||||
asset_content_type: application/octet-stream |
||||
- name: Upload Snicker Asset |
||||
uses: actions/upload-release-asset@v1 |
||||
env: |
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
with: |
||||
upload_url: ${{ needs.create_release.outputs.upload_url }} |
||||
asset_path: dist/joinmarket-clientserver-snicker-${{ github.ref_name}}.tgz |
||||
asset_name: joinmarket-clientserver-snicker-${{ github.ref_name}}.tgz |
||||
asset_content_type: application/octet-stream |
||||
- name: Upload ObWatch Asset |
||||
uses: actions/upload-release-asset@v1 |
||||
env: |
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
with: |
||||
upload_url: ${{ needs.create_release.outputs.upload_url }} |
||||
asset_path: dist/joinmarket-clientserver-obwatch-${{ github.ref_name}}.tgz |
||||
asset_name: joinmarket-clientserver-obwatch-${{ github.ref_name}}.tgz |
||||
asset_content_type: application/octet-stream |
||||
- name: Upload start-dn Asset |
||||
uses: actions/upload-release-asset@v1 |
||||
env: |
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
||||
with: |
||||
upload_url: ${{ needs.create_release.outputs.upload_url }} |
||||
asset_path: dist/joinmarket-clientserver-start-dn-${{ github.ref_name}}.tgz |
||||
asset_name: joinmarket-clientserver-start-dn-${{ github.ref_name}}.tgz |
||||
asset_content_type: application/octet-stream |
||||
@ -0,0 +1,155 @@
|
||||
#!/bin/bash |
||||
|
||||
set -e |
||||
|
||||
export JM_VERSION="${JM_VERSION:-0.1-testbuild}" |
||||
|
||||
PROJECT_ROOT=$(realpath "$(dirname "$(readlink -e "$0")")/../../..") |
||||
VENVPATH=$PROJECT_ROOT/jmvenv |
||||
JM_ROOT=$PROJECT_ROOT |
||||
|
||||
sudo apt-get install -y python3-dev python3-pip python3-venv git \ |
||||
build-essential automake pkg-config libtool libffi-dev libssl-dev |
||||
|
||||
|
||||
python3.12 -m venv $VENVPATH |
||||
|
||||
|
||||
. $VENVPATH/bin/activate |
||||
|
||||
|
||||
pip install .[gui] |
||||
pip install pyinstaller==6.14.2 |
||||
|
||||
# need to regenerate twisted/plugins/dropin.cache |
||||
python -c \ |
||||
'from twisted.plugin import IPlugin, getPlugins; list(getPlugins(IPlugin))' |
||||
|
||||
rm -rf deps |
||||
mkdir -p deps |
||||
cd deps |
||||
git clone https://github.com/bitcoin-core/secp256k1.git |
||||
cd secp256k1 |
||||
git checkout v0.6.0 |
||||
./autogen.sh |
||||
./configure --prefix $VENVPATH --enable-module-recovery \ |
||||
--enable-experimental --enable-module-ecdh --enable-benchmark=no |
||||
make |
||||
make check |
||||
make install |
||||
cd ../.. |
||||
|
||||
|
||||
rm -rf libsodium |
||||
git clone https://github.com/jedisct1/libsodium.git |
||||
cd libsodium |
||||
git checkout 1.0.20-RELEASE |
||||
./autogen.sh |
||||
./configure --prefix $VENVPATH |
||||
make check |
||||
sudo make install |
||||
cd .. |
||||
rm -rf libsodium |
||||
|
||||
cp contrib/build-linux/pyinstaller-build/joinmarket-clientserver.spec . |
||||
|
||||
pyinstaller -y joinmarket-clientserver.spec |
||||
|
||||
ls -l dist/joinmarket-clientserver/ |
||||
|
||||
cd dist |
||||
|
||||
mv joinmarket-clientserver joinmarket-clientserver-${JM_VERSION} |
||||
|
||||
tar -czvf joinmarket-clientserver-${JM_VERSION}.tgz \ |
||||
joinmarket-clientserver-${JM_VERSION} |
||||
|
||||
ls -l |
||||
|
||||
cd .. |
||||
|
||||
rm -rf build joinmarket-clientserver.spec |
||||
|
||||
cp contrib/build-linux/pyinstaller-build/joinmarket-clientserver-qt.spec . |
||||
|
||||
pyinstaller -y joinmarket-clientserver-qt.spec |
||||
|
||||
ls -l dist/joinmarket-clientserver/ |
||||
|
||||
cd dist |
||||
|
||||
mv joinmarket-clientserver joinmarket-clientserver-qt-${JM_VERSION} |
||||
|
||||
tar -czvf joinmarket-clientserver-qt-${JM_VERSION}.tgz \ |
||||
joinmarket-clientserver-qt-${JM_VERSION} |
||||
|
||||
ls -l |
||||
|
||||
cd .. |
||||
|
||||
rm -rf build joinmarket-clientserver-qt.spec |
||||
|
||||
cp contrib/build-linux/pyinstaller-build/joinmarket-clientserver-snicker.spec . |
||||
|
||||
pyinstaller -y joinmarket-clientserver-snicker.spec |
||||
|
||||
ls -l dist/joinmarket-clientserver/ |
||||
|
||||
cd dist |
||||
|
||||
mv joinmarket-clientserver joinmarket-clientserver-snicker-${JM_VERSION} |
||||
|
||||
tar -czvf joinmarket-clientserver-snicker-${JM_VERSION}.tgz \ |
||||
joinmarket-clientserver-snicker-${JM_VERSION} |
||||
|
||||
ls -l |
||||
|
||||
cd .. |
||||
|
||||
rm -rf build joinmarket-clientserver-snicker.spec |
||||
|
||||
cp contrib/build-linux/pyinstaller-build/joinmarket-clientserver-obwatch.spec . |
||||
|
||||
pyinstaller -y joinmarket-clientserver-obwatch.spec |
||||
|
||||
ls -l dist/joinmarket-clientserver/ |
||||
|
||||
cd dist |
||||
|
||||
mv joinmarket-clientserver joinmarket-clientserver-obwatch-${JM_VERSION} |
||||
|
||||
tar -czvf joinmarket-clientserver-obwatch-${JM_VERSION}.tgz \ |
||||
joinmarket-clientserver-obwatch-${JM_VERSION} |
||||
|
||||
ls -l |
||||
|
||||
cd .. |
||||
|
||||
rm -rf build joinmarket-clientserver-obwatch.spec |
||||
|
||||
git clone https://github.com/zebra-lucky/joinmarket-custom-scripts |
||||
|
||||
cd joinmarket-custom-scripts/ |
||||
|
||||
git checkout add-testnet4 |
||||
|
||||
cd .. |
||||
|
||||
cp contrib/build-linux/pyinstaller-build/start-dn.spec . |
||||
|
||||
pyinstaller -y start-dn.spec |
||||
|
||||
ls -l dist/joinmarket-clientserver/ |
||||
|
||||
cd dist |
||||
|
||||
mv joinmarket-clientserver joinmarket-clientserver-start-dn-${JM_VERSION} |
||||
|
||||
tar -czvf joinmarket-clientserver-start-dn-${JM_VERSION}.tgz \ |
||||
joinmarket-clientserver-start-dn-${JM_VERSION} |
||||
|
||||
ls -l |
||||
|
||||
cd .. |
||||
|
||||
rm -rf build joinmarket-custom-scripts start-dn.spec |
||||
@ -0,0 +1,84 @@
|
||||
# -*- mode: python; coding: utf-8 -*- |
||||
|
||||
import itertools |
||||
import os |
||||
from pathlib import Path |
||||
|
||||
|
||||
PROJECT_ROOT = os.path.abspath('.') |
||||
|
||||
|
||||
binaries = [] |
||||
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')] |
||||
|
||||
|
||||
datas = [] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/dropin.cache','twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/__init__.py', 'twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/scripts/obwatch/orderbook.html', '.')] |
||||
datas += [(f'{PROJECT_ROOT}/scripts/obwatch/vendor/bootstrap.min.css', |
||||
'vendor')] |
||||
datas += [(f'{PROJECT_ROOT}/scripts/obwatch/vendor/jquery-3.5.1.slim.min.js', |
||||
'vendor')] |
||||
datas += [(f'{PROJECT_ROOT}/scripts/obwatch/vendor/sorttable.js', |
||||
'vendor')] |
||||
|
||||
|
||||
scripts = [ |
||||
'scripts/obwatch/ob-watcher.py', |
||||
] |
||||
|
||||
|
||||
hiddenimports = [ |
||||
'chromalog.mark.helpers', |
||||
'chromalog.mark.helpers.simple', |
||||
'twisted.plugins', |
||||
'twisted.plugins.txtorcon_endpoint_parser', |
||||
] |
||||
|
||||
|
||||
a = {} |
||||
pyz = {} |
||||
exe = {} |
||||
|
||||
|
||||
for s in scripts: |
||||
a[s] = Analysis( |
||||
[s], |
||||
pathex=[], |
||||
binaries=binaries, |
||||
datas=datas, |
||||
hiddenimports=hiddenimports, |
||||
hookspath=[], |
||||
hooksconfig={}, |
||||
runtime_hooks=[], |
||||
excludes=[], |
||||
noarchive=False, |
||||
optimize=0, |
||||
) |
||||
|
||||
pyz[s] = PYZ(a[s].pure) |
||||
|
||||
exe[s] = EXE( |
||||
pyz[s], a[s].scripts, [], |
||||
name=Path(s).stem, |
||||
exclude_binaries=True, debug=False, bootloader_ignore_signals=False, |
||||
strip=False, upx=True, console=True, disable_windowed_traceback=False, |
||||
argv_emulation=False, target_arch=None, codesign_identity=None, |
||||
entitlements_file=None, |
||||
) |
||||
|
||||
|
||||
coll = COLLECT( |
||||
*list(exe.values()), |
||||
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))), |
||||
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))), |
||||
strip=False, |
||||
upx=True, |
||||
upx_exclude=[], |
||||
name='joinmarket-clientserver', |
||||
) |
||||
@ -0,0 +1,77 @@
|
||||
# -*- mode: python; coding: utf-8 -*- |
||||
|
||||
import itertools |
||||
import os |
||||
from pathlib import Path |
||||
|
||||
|
||||
PROJECT_ROOT = os.path.abspath('.') |
||||
|
||||
|
||||
binaries = [] |
||||
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')] |
||||
|
||||
|
||||
datas = [] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/dropin.cache','twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/__init__.py', 'twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')] |
||||
|
||||
|
||||
scripts = [ |
||||
'scripts/joinmarket-qt.py', |
||||
] |
||||
|
||||
|
||||
hiddenimports = [ |
||||
'chromalog.mark.helpers', |
||||
'chromalog.mark.helpers.simple', |
||||
'twisted.plugins', |
||||
'twisted.plugins.txtorcon_endpoint_parser', |
||||
] |
||||
|
||||
|
||||
a = {} |
||||
pyz = {} |
||||
exe = {} |
||||
|
||||
|
||||
for s in scripts: |
||||
a[s] = Analysis( |
||||
[s], |
||||
pathex=[], |
||||
binaries=binaries, |
||||
datas=datas, |
||||
hiddenimports=hiddenimports, |
||||
hookspath=[], |
||||
hooksconfig={}, |
||||
runtime_hooks=[], |
||||
excludes=[], |
||||
noarchive=False, |
||||
optimize=0, |
||||
) |
||||
|
||||
pyz[s] = PYZ(a[s].pure) |
||||
|
||||
exe[s] = EXE( |
||||
pyz[s], a[s].scripts, [], |
||||
name=Path(s).stem, |
||||
exclude_binaries=True, debug=False, bootloader_ignore_signals=False, |
||||
strip=False, upx=True, console=True, disable_windowed_traceback=False, |
||||
argv_emulation=False, target_arch=None, codesign_identity=None, |
||||
entitlements_file=None, |
||||
) |
||||
|
||||
|
||||
coll = COLLECT( |
||||
*list(exe.values()), |
||||
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))), |
||||
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))), |
||||
strip=False, |
||||
upx=True, |
||||
upx_exclude=[], |
||||
name='joinmarket-clientserver', |
||||
) |
||||
@ -0,0 +1,82 @@
|
||||
# -*- mode: python; coding: utf-8 -*- |
||||
|
||||
import itertools |
||||
import os |
||||
from pathlib import Path |
||||
|
||||
|
||||
PROJECT_ROOT = os.path.abspath('.') |
||||
|
||||
|
||||
binaries = [] |
||||
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')] |
||||
|
||||
|
||||
datas = [] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/dropin.cache','twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/__init__.py', 'twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')] |
||||
|
||||
|
||||
scripts = [ |
||||
'scripts/snicker/create-snicker-proposal.py', |
||||
'scripts/snicker/receive-snicker.py', |
||||
'scripts/snicker/snicker-finder.py', |
||||
'scripts/snicker/snicker-recovery.py', |
||||
'scripts/snicker/snicker-seed-tx.py', |
||||
'scripts/snicker/snicker-server.py', |
||||
] |
||||
|
||||
|
||||
hiddenimports = [ |
||||
'chromalog.mark.helpers', |
||||
'chromalog.mark.helpers.simple', |
||||
'twisted.plugins', |
||||
'twisted.plugins.txtorcon_endpoint_parser', |
||||
] |
||||
|
||||
|
||||
a = {} |
||||
pyz = {} |
||||
exe = {} |
||||
|
||||
|
||||
for s in scripts: |
||||
a[s] = Analysis( |
||||
[s], |
||||
pathex=[], |
||||
binaries=binaries, |
||||
datas=datas, |
||||
hiddenimports=hiddenimports, |
||||
hookspath=[], |
||||
hooksconfig={}, |
||||
runtime_hooks=[], |
||||
excludes=[], |
||||
noarchive=False, |
||||
optimize=0, |
||||
) |
||||
|
||||
pyz[s] = PYZ(a[s].pure) |
||||
|
||||
exe[s] = EXE( |
||||
pyz[s], a[s].scripts, [], |
||||
name=Path(s).stem, |
||||
exclude_binaries=True, debug=False, bootloader_ignore_signals=False, |
||||
strip=False, upx=True, console=True, disable_windowed_traceback=False, |
||||
argv_emulation=False, target_arch=None, codesign_identity=None, |
||||
entitlements_file=None, |
||||
) |
||||
|
||||
|
||||
coll = COLLECT( |
||||
*list(exe.values()), |
||||
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))), |
||||
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))), |
||||
strip=False, |
||||
upx=True, |
||||
upx_exclude=[], |
||||
name='joinmarket-clientserver', |
||||
) |
||||
@ -0,0 +1,91 @@
|
||||
# -*- mode: python; coding: utf-8 -*- |
||||
|
||||
import itertools |
||||
import os |
||||
from pathlib import Path |
||||
|
||||
|
||||
PROJECT_ROOT = os.path.abspath('.') |
||||
|
||||
|
||||
binaries = [] |
||||
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')] |
||||
|
||||
|
||||
datas = [] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/dropin.cache','twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/__init__.py', 'twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')] |
||||
|
||||
|
||||
scripts = [ |
||||
'scripts/add-utxo.py', |
||||
'scripts/bond-calculator.py', |
||||
'scripts/bumpfee.py', |
||||
'scripts/genwallet.py', |
||||
'scripts/jmwalletd.py', |
||||
'scripts/joinmarketd.py', |
||||
'scripts/receive-payjoin.py', |
||||
'scripts/sendpayment.py', |
||||
'scripts/sendtomany.py', |
||||
'scripts/tumbler.py', |
||||
'scripts/wallet-tool.py', |
||||
'scripts/yg-privacyenhanced.py', |
||||
'scripts/yield-generator-basic.py', |
||||
] |
||||
|
||||
|
||||
hiddenimports = [ |
||||
'chromalog.mark.helpers', |
||||
'chromalog.mark.helpers.simple', |
||||
'twisted.plugins', |
||||
# 'twisted.plugins.autobahn_endpoints', |
||||
# 'twisted.plugins.autobahn_twistd', |
||||
'twisted.plugins.txtorcon_endpoint_parser', |
||||
] |
||||
|
||||
|
||||
a = {} |
||||
pyz = {} |
||||
exe = {} |
||||
|
||||
|
||||
for s in scripts: |
||||
a[s] = Analysis( |
||||
[s], |
||||
pathex=[], |
||||
binaries=binaries, |
||||
datas=datas, |
||||
hiddenimports=hiddenimports, |
||||
hookspath=[], |
||||
hooksconfig={}, |
||||
runtime_hooks=[], |
||||
excludes=[], |
||||
noarchive=False, |
||||
optimize=0, |
||||
) |
||||
|
||||
pyz[s] = PYZ(a[s].pure) |
||||
|
||||
exe[s] = EXE( |
||||
pyz[s], a[s].scripts, [], |
||||
name=Path(s).stem, |
||||
exclude_binaries=True, debug=False, bootloader_ignore_signals=False, |
||||
strip=False, upx=True, console=True, disable_windowed_traceback=False, |
||||
argv_emulation=False, target_arch=None, codesign_identity=None, |
||||
entitlements_file=None, |
||||
) |
||||
|
||||
|
||||
coll = COLLECT( |
||||
*list(exe.values()), |
||||
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))), |
||||
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))), |
||||
strip=False, |
||||
upx=True, |
||||
upx_exclude=[], |
||||
name='joinmarket-clientserver', |
||||
) |
||||
@ -0,0 +1,79 @@
|
||||
# -*- mode: python; coding: utf-8 -*- |
||||
|
||||
import itertools |
||||
import os |
||||
from pathlib import Path |
||||
|
||||
|
||||
PROJECT_ROOT = os.path.abspath('.') |
||||
|
||||
|
||||
binaries = [] |
||||
binaries += [(f'{PROJECT_ROOT}/jmvenv/lib/lib*', '.')] |
||||
|
||||
|
||||
datas = [] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/dropin.cache','twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/__init__.py', 'twisted/plugins')] |
||||
datas += [(f'{PROJECT_ROOT}/jmvenv/lib/python3.12/site-packages/' |
||||
f'twisted/plugins/txtorcon_endpoint_parser.py', 'twisted/plugins')] |
||||
|
||||
|
||||
scripts = [ |
||||
'joinmarket-custom-scripts/start-dn.py', |
||||
] |
||||
|
||||
|
||||
hiddenimports = [ |
||||
'chromalog.mark.helpers', |
||||
'chromalog.mark.helpers.simple', |
||||
'twisted.plugins', |
||||
# 'twisted.plugins.autobahn_endpoints', |
||||
# 'twisted.plugins.autobahn_twistd', |
||||
'twisted.plugins.txtorcon_endpoint_parser', |
||||
] |
||||
|
||||
|
||||
a = {} |
||||
pyz = {} |
||||
exe = {} |
||||
|
||||
|
||||
for s in scripts: |
||||
a[s] = Analysis( |
||||
[s], |
||||
pathex=[], |
||||
binaries=binaries, |
||||
datas=datas, |
||||
hiddenimports=hiddenimports, |
||||
hookspath=[], |
||||
hooksconfig={}, |
||||
runtime_hooks=[], |
||||
excludes=[], |
||||
noarchive=False, |
||||
optimize=0, |
||||
) |
||||
|
||||
pyz[s] = PYZ(a[s].pure) |
||||
|
||||
exe[s] = EXE( |
||||
pyz[s], a[s].scripts, [], |
||||
name=Path(s).stem, |
||||
exclude_binaries=True, debug=False, bootloader_ignore_signals=False, |
||||
strip=False, upx=True, console=True, disable_windowed_traceback=False, |
||||
argv_emulation=False, target_arch=None, codesign_identity=None, |
||||
entitlements_file=None, |
||||
) |
||||
|
||||
|
||||
coll = COLLECT( |
||||
*list(exe.values()), |
||||
list(set(itertools.chain.from_iterable(b.binaries for b in a.values()))), |
||||
list(set(itertools.chain.from_iterable(d.datas for d in a.values()))), |
||||
strip=False, |
||||
upx=True, |
||||
upx_exclude=[], |
||||
name='joinmarket-clientserver', |
||||
) |
||||
@ -0,0 +1,306 @@
|
||||
# FROST P2TR wallet development details |
||||
|
||||
**NOTE**: minimal python version is python3.12 |
||||
|
||||
## FrostWallet storages |
||||
`FrostWallet` have two additional storages in addtion to wallet `Storage`: |
||||
- `DKGStorage` with DKG data |
||||
- `DKGRecoveryStorage` with DKG recovery data (unencrypted) |
||||
|
||||
They are loaded only for DKG/FROST support and not loaded on usual wallet |
||||
usage. |
||||
|
||||
Usual wallet usage interact with FROST/DKG functionality via IPC code in |
||||
`frost_ipc.py` (currently `AF_UNIX` socket for simplicity). |
||||
|
||||
`jmclient.wallet_utils.open_wallet` has two new parameters: |
||||
- `load_dkg=False`: by default do not load `DKGStorage` |
||||
- `dkg_read_only=True`: load `DKGStorage` for read only commands |
||||
|
||||
Additionally `open_wallet` params `read_only` and `dkg_read_only` can not |
||||
be mutually unset by design. |
||||
|
||||
|
||||
## Structure of DKG data in the DKGStorage |
||||
|
||||
``` |
||||
"dkg": { |
||||
"sessions": { |
||||
"md_type_idx": session_id, |
||||
... |
||||
}, |
||||
"pubkey": { |
||||
"session_id": threshold_pubkey, |
||||
... |
||||
}, |
||||
"pubshares": { |
||||
"session_id": [pubshare1, pubshare2, ...], |
||||
... |
||||
}, |
||||
"secshare": { |
||||
"session_id": secshare, |
||||
... |
||||
}, |
||||
"hostpubkeys": { |
||||
"session_id": [hostpubkey1, hostpubkey2, ...], |
||||
... |
||||
}, |
||||
"t": { |
||||
"session_id": t, |
||||
... |
||||
} |
||||
} |
||||
``` |
||||
Where `md_type_idx` is a serialization in bytes of `mixdepth`, `address_type`, |
||||
`index` of pubkey as in the HD wallet derivations. |
||||
|
||||
## Overall information |
||||
In the code used twisted `asyncioreactor` in place of standard twisted reactor. |
||||
Initialization is done as early as possible in `jmclient/__init__.py`. |
||||
Classes for wallets: `TaprootWallet`, `FrostWallet` in the `jmclient/wallet.py` |
||||
Utility class `DKGManager` in the `jmclient/wallet.py`. |
||||
Engine classes `BTC_P2TR(BTCEngine)`, `BTC_P2TR_FROST(BTC_P2TR)` in the |
||||
`jmclient/cryptoengine.py`. |
||||
|
||||
## `scripts/wallet-tool.py` commands |
||||
|
||||
- `hostpubkey`: display host public key |
||||
- `servefrost`: run only as DKG/FROST support (separate process which need |
||||
to be run permanently) |
||||
- `dkgrecover`: recover DKG sessions from DKG recovery file |
||||
- `dkgls`: display FrostWallet DKG data |
||||
- `dkgrm`: rm FrostWallet DKG data by `session_id` list |
||||
- `recdkgls`: display Recovery DKG File data |
||||
- `recdkgrm`: rm Recovery DKG File data by `session_id` list |
||||
- `testdkg`: run only as test of DKG process |
||||
- `testfrost`: run only as test of FROST signing |
||||
|
||||
## Description of `jmclient/frost_clients.py` |
||||
|
||||
- `class DKGClient`: clent which support only DKG sessions over JM channels. |
||||
Uses `chilldkg` reference code from |
||||
https://github.com/BlockstreamResearch/bip-frost-dkg/, placed in the |
||||
`jmfrost/chilldkg_ref` package. |
||||
|
||||
Uses channel level commands `dkginit`, `dkgpmsg1`, `dkgcmsg1`, `dkgpmsg2`, |
||||
`dkgcmsg2`, `dkgfinalized`, added to `jmdaemon/protocol.py`. |
||||
|
||||
NOTE: `dkgfinalized` is used to ensure all DKG party saw `dkgcmsg2` and |
||||
saved DKG data to wallet/recovery data. |
||||
|
||||
Commands in the `jmbase/commands.py`: `JMDKGInit`, `JMDKGPMsg1`, `JMDKGCMsg1`, |
||||
`JMDKGPMsg2`, `MDKGCMsg2`, `JMDKGFinalized`, `JMDKGInitSeen`, `JMDKGPMsg1Seen`, |
||||
`JMDKGCMsg1Seen`, `JMDKGPMsg2Seen`, `JMDKGCMsg2Seen`, `JMDKGFinalizedSeen`. |
||||
|
||||
Responders on the commands in the `jmclient/client_protocol.py`, |
||||
`jmdaemon/daemon_protocol.py`. |
||||
|
||||
In the DKG sessions the party which need new pubkey is named Coordinator. |
||||
|
||||
- `class FROSTClient(DKGClient)`: clent which support DKG/FROST sessions over |
||||
JM channels. Uses reference FROST code from |
||||
https://github.com/siv2r/bip-frost-signing/, placed in the |
||||
`jmfrost/frost_ref` package. |
||||
|
||||
Uses channel level commands `frostreq`, `frostack`, `frostinit`, `frostround1`, |
||||
`frostagg1`, `frostround2`, added to `jmdaemon/protocol.py`. |
||||
|
||||
Commands in the `jmbase/commands.py`: `JMFROSTReq`, `JMFROSTAck`, |
||||
`JMFROSTInit`, `JMFROSTRound1`, `JMFROSTAgg1`, `JMFROSTRound2`, |
||||
`JMFROSTReqSeen`, `JMFROSTAckSeen`, `JMFROSTInitSeen`, `JMFROSTRound1Seen`, |
||||
`JMFROSTAgg1Seen`, `JMFROSTRound2Seen`. |
||||
|
||||
Responders on the commands in the `jmclient/client_protocol.py`, |
||||
`jmdaemon/daemon_protocol.py`. |
||||
|
||||
In the FROST sessions the party which need new signature is named Coordinator. |
||||
|
||||
## Details on DKG message channel commands |
||||
|
||||
| Coordinator | | Party | |
||||
| :---------:|:----:|:-------:| |
||||
|!dkginit (public)|>>>|| |
||||
||<<<|!dkgpmsg1 (private unencrypted)| |
||||
|!dkgcmsg1 (private unencrypted)|>>>|| |
||||
||<<<|!dkgpmsg2 (private unencrypted)| |
||||
|!dkgcmsg2 (private unencrypted)|>>>|| |
||||
||<<<|!dkgfinalied (private unencrypted)| |
||||
|
||||
**dkginit**: public broadcast command from coordinator to request DKG |
||||
exchange |
||||
|
||||
``` |
||||
self.mcc.pubmsg(f'!dkginit {hostpubkeyhash} {session_id} {sig}') |
||||
``` |
||||
|
||||
- `hostpubkeyhash`: sha256 hash of wallet `hostpubkey` to identify |
||||
wallet to other DKG parties |
||||
- `session_id`: random 32 bytes to identify DKG session |
||||
- `sig`: Schnorr signature on `session_id` to verify with `hostpubkey` to |
||||
authenticate wallet |
||||
|
||||
**dkgpmsg1**: private unencrypted command from parties to authenticate and |
||||
send EncPedPop `pmsg1` to coordinator |
||||
|
||||
``` |
||||
msg = f'{hostpubkeyhash} {session_id} {sig} {pmsg1}' |
||||
self.mcc.prepare_privmsg(nick, "dkgpmsg1", msg) |
||||
``` |
||||
|
||||
- `hostpubkeyhash`: sha256 hash of wallet `hostpubkey` to identify |
||||
wallet to coordinator |
||||
- `session_id`: 32 bytes to idenify DKG session |
||||
- `sig`: Schnorr signature on `session_id` to verify with `hostpubkey` to |
||||
authenticate wallet |
||||
- `pmsg1`: EncPedPop participants step1 message |
||||
|
||||
**dkgcmsg1**: private unencrypted command from coordinator to send |
||||
EncPedPop `cmsg1` to DKG parties |
||||
|
||||
``` |
||||
msg = f'{session_id} {cmsg1}' |
||||
self.mcc.prepare_privmsg(nick, "dkgcmsg1", msg) |
||||
``` |
||||
|
||||
- `session_id`: 32 bytes to idenify DKG session |
||||
- `cmsg1`: EncPedPop coordinator step1 message |
||||
|
||||
**dkgpmsg2**: private unencrypted command from parties to send |
||||
EncPedPop `pmsg2` to coordinator |
||||
|
||||
``` |
||||
msg = f'{session_id} {pmsg2}' |
||||
self.mcc.prepare_privmsg(nick, "dkgpmsg2", msg) |
||||
``` |
||||
|
||||
- `session_id`: 32 bytes to idenify DKG session |
||||
- `pmsg2`: EncPedPop participants step2 message |
||||
|
||||
**dkgcmsg2**: private unencrypted command from coordinator to send |
||||
EncPedPop `cmsg2` and encrypted `ext_recovery` to DKG parties |
||||
|
||||
``` |
||||
msg = f'{session_id} {cmsg2} {ext_recovery}' |
||||
self.mcc.prepare_privmsg(nick, "dkgcmsg2", msg) |
||||
``` |
||||
|
||||
- `session_id`: 32 bytes to idenify DKG session |
||||
- `cmsg2`: EncPedPop coordinator step2 message |
||||
- `ext_recovery`: byte encoded and encrypted with `hostpubkey` tuple |
||||
`(mixdepth, address_type, index)`, which sent to DKG parties to write |
||||
with DKG recovery data |
||||
|
||||
**dkgfinalized**: private unencrypted command from parties to coordinator |
||||
to confirm DKG session finished and all DKG data saved together with |
||||
`recovery data`, `ext_recovery` |
||||
|
||||
``` |
||||
msg = f'{session_id}' |
||||
self.mcc.prepare_privmsg(nick, "dkgfinalized", msg) |
||||
``` |
||||
|
||||
- `session_id`: 32 bytes to idenify DKG session |
||||
|
||||
## Details on FROST message channel commands |
||||
|
||||
| Coordinator | | Party | |
||||
| :---------:|:----:|:-------:| |
||||
|!frostreq (public)|>>>|| |
||||
||<<<|!frostack (private unencrypted)| |
||||
|!frostinit (private encrypted)|>>>|| |
||||
||<<<|!frostround1 (private encrypted)| |
||||
|!frostagg1 (private encrypted)|>>>|| |
||||
||<<<|!frostround2 (private encrypted)| |
||||
|
||||
**frostreq**: public broadcast command from coordinator to request encrypted |
||||
FROST exchange |
||||
|
||||
``` |
||||
req_msg = f'!frostreq {hostpubkeyhash} {sig} {session_id} {dh_pubk}' |
||||
self.mcc.pubmsg(req_msg) |
||||
``` |
||||
|
||||
- `hostpubkeyhash`: sha256 hash of wallet `hostpubkey` to identify |
||||
wallet to other FROST parties |
||||
- `sig`: Schnorr signature on `session_id` to verify with `hostpubkey` to |
||||
authenticate wallet |
||||
- `session_id`: random 32 bytes to identify FROST session |
||||
- `dh_pubk`: ECDH public key to create encrypted private messages for other |
||||
FROST commands |
||||
|
||||
**frostack**: private unencrypted command from parties to acknowledge encrypted |
||||
FROST exchange |
||||
|
||||
``` |
||||
ack_msg = f'{hostpubkeyhash} {sig} {session_id} {dh_pubk} |
||||
self.mcc.prepare_privmsg(nick, 'frostack', ack_msg) |
||||
``` |
||||
|
||||
- `hostpubkeyhash`: sha256 hash of wallet `hostpubkey` to identify |
||||
wallet for coordinator |
||||
- `sig`: Schnorr signature on `session_id` to verify with `hostpubkey` to |
||||
authenticate wallet |
||||
- `session_id`: 32 bytes to idenify FROST session |
||||
- `dh_pubk`: ECDH public key to create encrypted private messages for other |
||||
FROST commands |
||||
|
||||
**frostinit**: private encrypted command from coordinator to initiate |
||||
FROST exchange |
||||
|
||||
``` |
||||
init_msg = f'{session_id}' |
||||
self.mcc.prepare_privmsg(nick, 'frostinit', init_msg) |
||||
``` |
||||
|
||||
- `session_id`: 32 bytes to idenify FROST session |
||||
|
||||
**frostround1**: private encrypted command from parties to send `pub_nonce` |
||||
part of FROST exchange |
||||
|
||||
``` |
||||
round1_msg = f'{session_id} pub_nonce}' |
||||
self.mcc.prepare_privmsg(nick, "frostround1", round1_msg) |
||||
``` |
||||
|
||||
- `session_id`: 32 bytes to idenify FROST session |
||||
- `pub_nonce`: public part of `sec_nonce`/`pub_nonce` pair |
||||
|
||||
**frostagg1**: private encrypted command from coorinator to send aggregated |
||||
nonces data, DKG session id to get key data, ids of sign parties and |
||||
message to sign |
||||
|
||||
``` |
||||
agg1_msg = f'{session_id} {nonce_agg} {dkg_session_id} {ids} {msg}' |
||||
self.mcc.prepare_privmsg(nick, "frostagg1", agg1_msg) |
||||
``` |
||||
|
||||
- `session_id`: 32 bytes to idenify FROST session |
||||
- `nonce_agg`: aggregated pub nonces data |
||||
- `dkg_session_id`: bytes to idenify DKG session where key data for FROST |
||||
whas generated |
||||
- `ids`: FROST sign parties ids |
||||
- `msg`: 32 bytes message to sign |
||||
|
||||
**frostround2**: private encrypted command from parties to send partial |
||||
signature for coordinator |
||||
|
||||
``` |
||||
msg = f'{session_id} {partial_sig}' |
||||
self.mcc.prepare_privmsg(nick, "frostround2", msg) |
||||
``` |
||||
|
||||
- `session_id`: 32 bytes to idenify FROST session |
||||
- `partial_sig`: partial FROST signature agregated later on coordinator |
||||
|
||||
## Recovery storage, recovery data file. |
||||
ChillDKG recovery data is placed in the unencrypted recovery file with |
||||
the name `wallet.jmdat.dkg_recovery`. Code of `class DKGRecoveryStorage` is |
||||
placed in `jmclient/storage.py` |
||||
|
||||
## Utility scripts |
||||
Currently changes in the code allow creation of unencrypted wallets, if |
||||
empty password is used. |
||||
- `scripts/bdecode.py`: allow decode wallet/recovery data files to stdout. |
||||
- `scripts/bencode.py`: allow allow encode text file to bencode format. |
||||
Separate options is presented to encode with DKG data file magic or DKG |
||||
recovery data file magic. |
||||
@ -0,0 +1,68 @@
|
||||
# FROST P2TR wallet usage |
||||
|
||||
**NOTE**: minimal python version is python3.12 |
||||
|
||||
To use FROST P2TR wallet you need (example for 2 of 3 FROST signing): |
||||
|
||||
1. Add `txindex=1` to `bitcoin.conf`. This options is need to get non-wallet |
||||
transactions with `getrawtransaction`. This data is need to perform signing |
||||
of P2TR inputs. |
||||
|
||||
2. Set `frost = true` in the `POLICY` section of `joinmarket.cfg`: |
||||
``` |
||||
[POLICY] |
||||
... |
||||
# Use FROST P2TR SegWit wallet |
||||
frost = true |
||||
``` |
||||
|
||||
3. Create bitcoind watchonly descriptors wallet: |
||||
``` |
||||
bitcoin-cli createwallet "wallet_name" true true |
||||
``` |
||||
where `true true` is: |
||||
> `disable_private_keys` |
||||
> Disable the possibility of private keys |
||||
> (only watchonlys are possible in this mode). |
||||
|
||||
> `blank` |
||||
> Create a blank wallet. A blank wallet has no keys or HD seed. |
||||
|
||||
4. Get `hostpubkey` for wallet by running: |
||||
``` |
||||
scripts/wallet-tool.py wallet.jmdat hostpubkey |
||||
... |
||||
021e99d8193b95da10f514556e98882bc2cebfd0ee0711fa71006cbc9e9a135b43 |
||||
``` |
||||
|
||||
5. Repeat steps 1-4 for other FROST group wallets. |
||||
|
||||
6. Gather hostpubkeys from step 4 and place to the `FROST` section |
||||
of `joinmarket.cfg` as the `hostpubkeys` value separated by `,`. |
||||
|
||||
7. Add `t` (threshold) value to the `FROST` section of `joinmarket.cfg`: |
||||
``` |
||||
[FROST] |
||||
hostpubkeys = 021e99d8193b95da...,03a2f4ce928da0f5...,02a1e2ee50187f3e... |
||||
t = 2 |
||||
``` |
||||
|
||||
8. Run permanent FROST processes with `servefrost` command on `wallet1`, |
||||
`wallet2`, `wallet3`: |
||||
``` |
||||
scripts/wallet-tool.py wallet.jmdat servefrost |
||||
``` |
||||
|
||||
9. Run `display` command on `wallet1` |
||||
``` |
||||
scripts/wallet-tool.py wallet.jmdat display |
||||
``` |
||||
The process of DKG sessions will start to generate pubkeys for the |
||||
wallet addresses. This can take several minutes. |
||||
|
||||
10. Repeat step 9 to generate pubkeys for `wallet2`, `wallet3`. |
||||
|
||||
11. Test FROST signing with `testfrost` command |
||||
``` |
||||
scripts/wallet-tool.py wallet.jmdat testfrost |
||||
``` |
||||
@ -0,0 +1,27 @@
|
||||
# Taproot P2TR wallet usage |
||||
|
||||
To use P2TR wallet you need: |
||||
|
||||
1. Add `txindex=1` to `bitcoin.conf`. This options is need to get non-wallet |
||||
transactions with `getrawtransaction`. This data is need to perform signing |
||||
of P2TR inputs. |
||||
|
||||
2. Set `taproot = true` in the `POLICY` section of `joinmarket.cfg`: |
||||
``` |
||||
[POLICY] |
||||
... |
||||
# Use Taproot P2TR SegWit wallet |
||||
taproot = true |
||||
``` |
||||
|
||||
3. Create bitcoind watchonly descriptors wallet: |
||||
``` |
||||
bitcoin-cli createwallet "wallet_name" true true |
||||
``` |
||||
where `true true` is: |
||||
> `disable_private_keys` |
||||
> Disable the possibility of private keys |
||||
> (only watchonlys are possible in this mode). |
||||
|
||||
> `blank` |
||||
> Create a blank wallet. A blank wallet has no keys or HD seed. |
||||
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3 |
||||
# -*- coding: utf-8 -*- |
||||
|
||||
import click |
||||
import json |
||||
from pprint import pprint |
||||
from fastbencode import bdecode |
||||
|
||||
|
||||
def debyte_list(lst): |
||||
res = [] |
||||
for item in lst: |
||||
if isinstance(item, bytes): |
||||
item = item.decode('ISO-8859-1') |
||||
elif isinstance(item, list): |
||||
item = debyte_list(item) |
||||
res.append(item) |
||||
return res |
||||
|
||||
|
||||
def debyte_dict(d): |
||||
res = {} |
||||
for k, v in d.items(): |
||||
if isinstance(k, bytes): |
||||
k = k.decode('ISO-8859-1') |
||||
if isinstance(v, dict): |
||||
v = debyte_dict(v) |
||||
elif isinstance(v, bytes): |
||||
v = v.decode('ISO-8859-1') |
||||
elif isinstance(v, list): |
||||
v = debyte_list(v) |
||||
res[k] = v |
||||
return res |
||||
|
||||
|
||||
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) |
||||
@click.command(context_settings=CONTEXT_SETTINGS) |
||||
@click.option('-i', '--input-file', required=True, |
||||
help='Input file') |
||||
@click.option('-n', '--no-decode', is_flag=True, default=False, |
||||
help='Do not decode to ISO-8859-1') |
||||
def main(**kwargs): |
||||
input_file = kwargs.pop('input_file') |
||||
no_decode = kwargs.pop('no_decode') |
||||
with open(input_file, 'rb') as fd: |
||||
data = fd.read() |
||||
if no_decode: |
||||
d = bdecode(data[8:]) |
||||
pprint(d) |
||||
else: |
||||
d = debyte_dict(bdecode(data[8:])) |
||||
print(json.dumps(d, indent=4)) |
||||
|
||||
|
||||
if __name__ == '__main__': |
||||
main() |
||||
@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env python3 |
||||
# -*- coding: utf-8 -*- |
||||
|
||||
import click |
||||
import json |
||||
from fastbencode import bencode_utf8 |
||||
|
||||
|
||||
def enbyte_list(lst): |
||||
res = [] |
||||
for item in lst: |
||||
if isinstance(item, str): |
||||
item = item.encode('ISO-8859-1') |
||||
elif isinstance(item, list): |
||||
item = enbyte_list(item) |
||||
res.append(item) |
||||
return res |
||||
|
||||
|
||||
def enbyte_dict(d): |
||||
res = {} |
||||
for k, v in d.items(): |
||||
if isinstance(k, str): |
||||
k = k.encode('ISO-8859-1') |
||||
if isinstance(v, dict): |
||||
v = enbyte_dict(v) |
||||
elif isinstance(v, str): |
||||
v = v.encode('ISO-8859-1') |
||||
elif isinstance(v, list): |
||||
v = enbyte_list(v) |
||||
res[k] = v |
||||
return res |
||||
|
||||
|
||||
CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) |
||||
@click.command(context_settings=CONTEXT_SETTINGS) |
||||
@click.option('-i', '--input-file', required=True, |
||||
help='Unencoded file') |
||||
@click.option('-o', '--output-file', required=True, |
||||
help='Output file') |
||||
@click.option('-d', '--dkg-magic', is_flag=True, default=False, |
||||
help='Prepend dkg storage magic') |
||||
@click.option('-r', '--recovery-magic', is_flag=True, default=False, |
||||
help='Prepend recovery storage magic') |
||||
def main(**kwargs): |
||||
input_file = kwargs.pop('input_file') |
||||
output_file = kwargs.pop('output_file') |
||||
dkg_magic = kwargs.pop('dkg_magic') |
||||
recovery_magic = kwargs.pop('recovery_magic') |
||||
if dkg_magic and recovery_magic: |
||||
raise click.UsageError('Options -d and -r is mutually exclusive') |
||||
if dkg_magic: |
||||
MAGIC_UNENC = b'JMDKGDAT' |
||||
elif recovery_magic: |
||||
MAGIC_UNENC = b'JMDKGREC' |
||||
else: |
||||
MAGIC_UNENC = b'JMWALLET' |
||||
|
||||
with open(input_file, 'r') as fd: |
||||
data = json.loads(fd.read()) |
||||
data = enbyte_dict(data) |
||||
|
||||
with open(output_file, 'wb') as wfd: |
||||
wfd.write(MAGIC_UNENC + bencode_utf8(data)) |
||||
|
||||
|
||||
if __name__ == '__main__': |
||||
main() |
||||
@ -1,6 +1,26 @@
|
||||
#!/usr/bin/env python3 |
||||
|
||||
import asyncio |
||||
|
||||
import jmclient # noqa: F401 install asyncioreactor |
||||
from twisted.internet import reactor |
||||
from jmclient.scripts_support import wrap_main, finalize_main_task |
||||
|
||||
from jmbase import jmprint |
||||
from jmclient import wallet_tool_main |
||||
|
||||
|
||||
@wrap_main |
||||
async def _main(): |
||||
res = await wallet_tool_main("wallets") |
||||
if res: |
||||
jmprint(res, "success") |
||||
else: |
||||
jmprint("Finished", "success") |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
jmprint(wallet_tool_main("wallets"), "success") |
||||
asyncio_loop = asyncio.get_event_loop() |
||||
main_task = asyncio_loop.create_task(_main()) |
||||
reactor.run() |
||||
finalize_main_task(main_task) |
||||
|
||||
@ -1,11 +1,26 @@
|
||||
#!/usr/bin/env python3 |
||||
|
||||
import asyncio |
||||
|
||||
import jmclient # noqa: F401 install asyncioreactor |
||||
from twisted.internet import reactor |
||||
from jmclient.scripts_support import wrap_main, finalize_main_task |
||||
|
||||
from jmbase import jmprint |
||||
from jmclient import YieldGeneratorBasic, ygmain |
||||
|
||||
# YIELD GENERATOR SETTINGS ARE NOW IN YOUR joinmarket.cfg CONFIG FILE |
||||
# (You can also use command line flags; see --help for this script). |
||||
|
||||
|
||||
@wrap_main |
||||
async def _main(): |
||||
await ygmain(YieldGeneratorBasic, nickserv_password='') |
||||
jmprint("done", "success") |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
ygmain(YieldGeneratorBasic, nickserv_password='') |
||||
jmprint('done', "success") |
||||
asyncio_loop = asyncio.get_event_loop() |
||||
main_task = asyncio_loop.create_task(_main()) |
||||
reactor.run() |
||||
finalize_main_task(main_task) |
||||
|
||||
@ -0,0 +1,243 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
import hashlib |
||||
from typing import List, Optional, Tuple, Union, Set, Type |
||||
|
||||
import bitcointx |
||||
from bitcointx.core.key import XOnlyPubKey |
||||
from bitcointx.core.script import SIGVERSION_TAPROOT, SignatureHashSchnorr |
||||
from bitcointx.core import CTxOut |
||||
from bitcointx.core.scripteval import ( |
||||
CScript, ScriptVerifyFlag_Type, CScriptWitness, VerifyScriptError, |
||||
STANDARD_SCRIPT_VERIFY_FLAGS, UNHANDLED_SCRIPT_VERIFY_FLAGS, EvalScript, |
||||
SCRIPT_VERIFY_CLEANSTACK, script_verify_flags_to_string, _CastToBool, |
||||
ensure_isinstance, SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM, |
||||
MAX_SCRIPT_ELEMENT_SIZE, OP_CHECKSIG, OP_EQUALVERIFY, OP_HASH160, OP_DUP) |
||||
from bitcointx.core.scripteval import ( |
||||
SCRIPT_VERIFY_WITNESS, SCRIPT_VERIFY_P2SH, SIGVERSION_WITNESS_V0) |
||||
|
||||
|
||||
def VerifyScriptWithTaproot( |
||||
scriptSig: CScript, scriptPubKey: CScript, |
||||
txTo: 'bitcointx.core.CTransaction', inIdx: int, |
||||
flags: Optional[Union[Tuple[ScriptVerifyFlag_Type, ...], |
||||
Set[ScriptVerifyFlag_Type]]] = None, |
||||
amount: int = 0, witness: Optional[CScriptWitness] = None, |
||||
*, |
||||
spent_outputs: Optional[List[CTxOut]] = None |
||||
) -> None: |
||||
"""Verify a scriptSig satisfies a scriptPubKey |
||||
|
||||
scriptSig - Signature |
||||
|
||||
scriptPubKey - PubKey |
||||
|
||||
txTo - Spending transaction |
||||
|
||||
inIdx - Index of the transaction input containing scriptSig |
||||
|
||||
Raises a ValidationError subclass if the validation fails. |
||||
""" |
||||
|
||||
ensure_isinstance(scriptSig, CScript, 'scriptSig') |
||||
if not type(scriptSig) == type(scriptPubKey): # noqa: exact class check |
||||
raise TypeError( |
||||
"scriptSig and scriptPubKey must be of the same script class") |
||||
|
||||
script_class = scriptSig.__class__ |
||||
|
||||
if flags is None: |
||||
flags = STANDARD_SCRIPT_VERIFY_FLAGS - UNHANDLED_SCRIPT_VERIFY_FLAGS |
||||
else: |
||||
flags = set(flags) # might be passed as tuple |
||||
|
||||
if flags & UNHANDLED_SCRIPT_VERIFY_FLAGS: |
||||
raise VerifyScriptError( |
||||
"some of the flags cannot be handled by current code: {}" |
||||
.format(script_verify_flags_to_string(flags & UNHANDLED_SCRIPT_VERIFY_FLAGS))) |
||||
|
||||
stack: List[bytes] = [] |
||||
EvalScript(stack, scriptSig, txTo, inIdx, flags=flags) |
||||
if SCRIPT_VERIFY_P2SH in flags: |
||||
stackCopy = list(stack) |
||||
EvalScript(stack, scriptPubKey, txTo, inIdx, flags=flags) |
||||
if len(stack) == 0: |
||||
raise VerifyScriptError("scriptPubKey left an empty stack") |
||||
if not _CastToBool(stack[-1]): |
||||
raise VerifyScriptError("scriptPubKey returned false") |
||||
|
||||
hadWitness = False |
||||
if witness is None: |
||||
witness = CScriptWitness([]) |
||||
|
||||
if SCRIPT_VERIFY_WITNESS in flags and scriptPubKey.is_witness_scriptpubkey(): |
||||
hadWitness = True |
||||
|
||||
if scriptSig: |
||||
raise VerifyScriptError("scriptSig is not empty") |
||||
|
||||
VerifyWitnessProgramWithTaproot( |
||||
witness, |
||||
scriptPubKey.witness_version(), |
||||
scriptPubKey.witness_program(), |
||||
txTo, inIdx, flags=flags, amount=amount, |
||||
script_class=script_class, |
||||
spent_outputs=spent_outputs) |
||||
|
||||
# Bypass the cleanstack check at the end. The actual stack is obviously not clean |
||||
# for witness programs. |
||||
stack = stack[:1] |
||||
|
||||
# Additional validation for spend-to-script-hash transactions |
||||
if SCRIPT_VERIFY_P2SH in flags and scriptPubKey.is_p2sh(): |
||||
if not scriptSig.is_push_only(): |
||||
raise VerifyScriptError("P2SH scriptSig not is_push_only()") |
||||
|
||||
# restore stack |
||||
stack = stackCopy |
||||
|
||||
# stack cannot be empty here, because if it was the |
||||
# P2SH HASH <> EQUAL scriptPubKey would be evaluated with |
||||
# an empty stack and the EvalScript above would return false. |
||||
assert len(stack) |
||||
|
||||
pubKey2 = script_class(stack.pop()) |
||||
|
||||
EvalScript(stack, pubKey2, txTo, inIdx, flags=flags) |
||||
|
||||
if not len(stack): |
||||
raise VerifyScriptError("P2SH inner scriptPubKey left an empty stack") |
||||
|
||||
if not _CastToBool(stack[-1]): |
||||
raise VerifyScriptError("P2SH inner scriptPubKey returned false") |
||||
|
||||
# P2SH witness program |
||||
if SCRIPT_VERIFY_WITNESS in flags and pubKey2.is_witness_scriptpubkey(): |
||||
hadWitness = True |
||||
|
||||
if scriptSig != script_class([pubKey2]): |
||||
raise VerifyScriptError("scriptSig is not exactly a single push of the redeemScript") |
||||
|
||||
VerifyWitnessProgramWithTaproot( |
||||
witness, |
||||
pubKey2.witness_version(), |
||||
pubKey2.witness_program(), |
||||
txTo, inIdx, flags=flags, amount=amount, |
||||
script_class=script_class, |
||||
spent_outputs=spent_outputs) |
||||
|
||||
# Bypass the cleanstack check at the end. The actual stack is obviously not clean |
||||
# for witness programs. |
||||
stack = stack[:1] |
||||
|
||||
if SCRIPT_VERIFY_CLEANSTACK in flags: |
||||
if SCRIPT_VERIFY_P2SH not in flags: |
||||
raise ValueError( |
||||
'SCRIPT_VERIFY_CLEANSTACK requires SCRIPT_VERIFY_P2SH') |
||||
|
||||
if len(stack) == 0: |
||||
raise VerifyScriptError("scriptPubKey left an empty stack") |
||||
elif len(stack) != 1: |
||||
raise VerifyScriptError("scriptPubKey left extra items on stack") |
||||
|
||||
if SCRIPT_VERIFY_WITNESS in flags: |
||||
# We can't check for correct unexpected witness data if P2SH was off, so require |
||||
# that WITNESS implies P2SH. Otherwise, going from WITNESS->P2SH+WITNESS would be |
||||
# possible, which is not a softfork. |
||||
if SCRIPT_VERIFY_P2SH not in flags: |
||||
raise ValueError( |
||||
"SCRIPT_VERIFY_WITNESS requires SCRIPT_VERIFY_P2SH") |
||||
|
||||
if not hadWitness and witness: |
||||
raise VerifyScriptError("Unexpected witness") |
||||
|
||||
|
||||
def VerifyWitnessProgramWithTaproot( |
||||
witness: CScriptWitness, |
||||
witversion: int, program: bytes, |
||||
txTo: 'bitcointx.core.CTransaction', |
||||
inIdx: int, |
||||
flags: Set[ScriptVerifyFlag_Type] = set(), |
||||
amount: int = 0, |
||||
script_class: Type[CScript] = CScript, |
||||
*, |
||||
spent_outputs: Optional[List[CTxOut]] = None |
||||
) -> None: |
||||
|
||||
if script_class is None: |
||||
raise ValueError("script class must be specified") |
||||
|
||||
sigversion = None |
||||
|
||||
if witversion == 0: |
||||
sigversion = SIGVERSION_WITNESS_V0 |
||||
stack = list(witness.stack) |
||||
if len(program) == 32: |
||||
# Version 0 segregated witness program: SHA256(CScript) inside the program, |
||||
# CScript + inputs in witness |
||||
if len(stack) == 0: |
||||
raise VerifyScriptError("witness is empty") |
||||
|
||||
scriptPubKey = script_class(stack.pop()) |
||||
hashScriptPubKey = hashlib.sha256(scriptPubKey).digest() |
||||
if hashScriptPubKey != program: |
||||
raise VerifyScriptError("witness program mismatch") |
||||
elif len(program) == 20: |
||||
# Special case for pay-to-pubkeyhash; signature + pubkey in witness |
||||
if len(stack) != 2: |
||||
raise VerifyScriptError("witness program mismatch") # 2 items in witness |
||||
|
||||
scriptPubKey = script_class([OP_DUP, OP_HASH160, program, |
||||
OP_EQUALVERIFY, OP_CHECKSIG]) |
||||
else: |
||||
raise VerifyScriptError("wrong length for witness program") |
||||
elif witversion == 1: |
||||
sigversion = SIGVERSION_TAPROOT |
||||
stack = list(witness.stack) |
||||
if len(program) == 32: |
||||
if len(stack) == 0: |
||||
raise VerifyScriptError("witness is empty") |
||||
if len(stack) != 1: |
||||
raise VerifyScriptError("only key path spend is supported") |
||||
assert spent_outputs |
||||
sig = stack[0] |
||||
pubkey = XOnlyPubKey(program) |
||||
sighash = SignatureHashSchnorr(txTo, inIdx, spent_outputs) |
||||
if pubkey.verify_schnorr(sighash, sig): |
||||
return |
||||
else: |
||||
raise VerifyScriptError("schnorr signature verify failed") |
||||
else: |
||||
raise VerifyScriptError("wrong length for witness program") |
||||
elif SCRIPT_VERIFY_DISCOURAGE_UPGRADABLE_WITNESS_PROGRAM in flags: |
||||
raise VerifyScriptError("upgradeable witness program is not accepted") |
||||
else: |
||||
# Higher version witness scripts return true for future softfork compatibility |
||||
return |
||||
|
||||
assert sigversion is not None |
||||
|
||||
for i, elt in enumerate(stack): |
||||
if isinstance(elt, int): |
||||
elt_len = len(script_class([elt])) |
||||
else: |
||||
elt_len = len(elt) |
||||
|
||||
# Disallow stack item size > MAX_SCRIPT_ELEMENT_SIZE in witness stack |
||||
if elt_len > MAX_SCRIPT_ELEMENT_SIZE: |
||||
raise VerifyScriptError( |
||||
"maximum push size exceeded by an item at position {} " |
||||
"on witness stack".format(i)) |
||||
|
||||
EvalScript(stack, scriptPubKey, txTo, inIdx, flags=flags, amount=amount, sigversion=sigversion) |
||||
|
||||
# Scripts inside witness implicitly require cleanstack behaviour |
||||
if len(stack) == 0: |
||||
raise VerifyScriptError("scriptPubKey left an empty stack") |
||||
elif len(stack) != 1: |
||||
raise VerifyScriptError("scriptPubKey left extra items on stack") |
||||
|
||||
if not _CastToBool(stack[-1]): |
||||
raise VerifyScriptError("scriptPubKey returned false") |
||||
|
||||
return |
||||
@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
INPUT_CHARSET = "0123456789()[],'/*abcdefgh@:$%{}IJKLMNOPQRSTUVWXYZ&+-.;<=>?!^_|~ijklmnopqrstuvwxyzABCDEFGH`#\"\\ " |
||||
CHECKSUM_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" |
||||
GENERATOR = [0xf5dee51989, 0xa9fdca3312, 0x1bab10e32d, 0x3706b1677a, 0x644d626ffd] |
||||
|
||||
def descsum_polymod(symbols): |
||||
"""Internal function that computes the descriptor checksum.""" |
||||
chk = 1 |
||||
for value in symbols: |
||||
top = chk >> 35 |
||||
chk = (chk & 0x7ffffffff) << 5 ^ value |
||||
for i in range(5): |
||||
chk ^= GENERATOR[i] if ((top >> i) & 1) else 0 |
||||
return chk |
||||
|
||||
def descsum_expand(s): |
||||
"""Internal function that does the character to symbol expansion""" |
||||
groups = [] |
||||
symbols = [] |
||||
for c in s: |
||||
if not c in INPUT_CHARSET: |
||||
return None |
||||
v = INPUT_CHARSET.find(c) |
||||
symbols.append(v & 31) |
||||
groups.append(v >> 5) |
||||
if len(groups) == 3: |
||||
symbols.append(groups[0] * 9 + groups[1] * 3 + groups[2]) |
||||
groups = [] |
||||
if len(groups) == 1: |
||||
symbols.append(groups[0]) |
||||
elif len(groups) == 2: |
||||
symbols.append(groups[0] * 3 + groups[1]) |
||||
return symbols |
||||
|
||||
def descsum_check(s): |
||||
"""Verify that the checksum is correct in a descriptor""" |
||||
if s[-9] != '#': |
||||
return False |
||||
if not all(x in CHECKSUM_CHARSET for x in s[-8:]): |
||||
return False |
||||
symbols = descsum_expand(s[:-9]) + [CHECKSUM_CHARSET.find(x) for x in s[-8:]] |
||||
return descsum_polymod(symbols) == 1 |
||||
|
||||
def descsum_create(s): |
||||
"""Add a checksum to a descriptor without""" |
||||
symbols = descsum_expand(s) + [0, 0, 0, 0, 0, 0, 0, 0] |
||||
checksum = descsum_polymod(symbols) ^ 1 |
||||
return s + '#' + ''.join(CHECKSUM_CHARSET[(checksum >> (5 * (7 - i))) & 31] for i in range(8)) |
||||
@ -0,0 +1,275 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
import asyncio |
||||
import pickle |
||||
|
||||
import jmbitcoin as btc |
||||
from jmbase.support import jmprint, EXIT_FAILURE, twisted_sys_exit, get_log |
||||
|
||||
|
||||
jlog = get_log() |
||||
|
||||
|
||||
class IPCBase: |
||||
|
||||
def encrypt_msg(self, msg_dict): |
||||
msg_bytes = pickle.dumps(msg_dict) |
||||
return btc.ecies_encrypt(msg_bytes, self.pubkey) + b'\n' |
||||
|
||||
def decrypt_msg(self, enc_bytes): |
||||
msg_bytes = btc.ecies_decrypt(self.wallet._hostseckey, enc_bytes) |
||||
return pickle.loads(msg_bytes) |
||||
|
||||
|
||||
class FrostIPCServer(IPCBase): |
||||
|
||||
def __init__(self, wallet): |
||||
self.loop = asyncio.get_event_loop() |
||||
self.wallet = wallet |
||||
self.pubkey = btc.privkey_to_pubkey(wallet._hostseckey) |
||||
self.sock_path = f'{wallet._storage.get_location()}.sock' |
||||
self.srv = None |
||||
self.sr = None |
||||
self.sw = None |
||||
self.tasks = set() |
||||
|
||||
async def async_init(self): |
||||
self.srv = await asyncio.start_unix_server( |
||||
self.handle_connection, self.sock_path) |
||||
|
||||
async def serve_forever(self): |
||||
return await self.srv.serve_forever() |
||||
|
||||
async def handle_connection(self, sr, sw): |
||||
if self.sr or self.sw: |
||||
jlog.error('FrostIPCServer.handle_connection: client ' |
||||
'already connected, ignore other connection attempt') |
||||
return |
||||
jlog.info('FrostIPCServer.handle_connection: connected new client') |
||||
self.sr = sr |
||||
self.sw = sw |
||||
await self.process_msgs() |
||||
|
||||
async def process_msgs(self): |
||||
while True: |
||||
try: |
||||
line_data = await self.sr.readline() |
||||
if not line_data: |
||||
if self.sr.at_eof(): |
||||
jlog.info('FrostIPCServer.process_msg: ' |
||||
'client disconnected') |
||||
self.sr = None |
||||
self.sw = None |
||||
while self.tasks: |
||||
task = self.tasks.pop() |
||||
task.cancel() |
||||
break |
||||
else: |
||||
jlog.error('FrostIPCServer.process_msg: ' |
||||
'empty line ignored') |
||||
continue |
||||
enc_bytes = line_data.strip() |
||||
msg_dict = self.decrypt_msg(enc_bytes) |
||||
msg_id = msg_dict['msg_id'] |
||||
cmd = msg_dict['cmd'] |
||||
data = msg_dict['data'] |
||||
task = None |
||||
if cmd == 'get_dkg_pubkey': |
||||
task = self.loop.create_task( |
||||
self.on_get_dkg_pubkey(msg_id, *data)) |
||||
elif cmd == 'frost_req': |
||||
task = self.loop.create_task( |
||||
self.on_frost_req(msg_id, *data)) |
||||
if task: |
||||
self.tasks.add(task) |
||||
except Exception as e: |
||||
jlog.error(f'FrostIPCServer.process_msgs: {repr(e)}') |
||||
await asyncio.sleep(0.1) |
||||
|
||||
async def on_get_dkg_pubkey(self, msg_id, mixdepth, address_type, index, |
||||
session_id=None): |
||||
try: |
||||
wallet = self.wallet |
||||
dkg = wallet.dkg |
||||
if session_id is not None: |
||||
client = wallet.client_factory.getClient() |
||||
frost_client = wallet.client_factory.client |
||||
frost_client.dkg_gen_list.append( |
||||
(mixdepth, address_type, index)) |
||||
new_pubkey = await client.dkg_gen(session_id=session_id) |
||||
else: |
||||
new_pubkey = dkg.find_dkg_pubkey(mixdepth, address_type, index) |
||||
if session_id is None and new_pubkey is None: |
||||
client = wallet.client_factory.getClient() |
||||
frost_client = wallet.client_factory.client |
||||
frost_client.dkg_gen_list.append( |
||||
(mixdepth, address_type, index)) |
||||
await client.dkg_gen() |
||||
new_pubkey = dkg.find_dkg_pubkey(mixdepth, address_type, index) |
||||
if new_pubkey: |
||||
await self.send_dkg_pubkey(msg_id, new_pubkey) |
||||
else: |
||||
raise Exception('No pubkey found or generated') |
||||
except Exception as e: |
||||
await self.send_dkg_pubkey(msg_id, None) |
||||
jlog.error(f'FrostIPCServer.on_get_dkg_pubkey: {repr(e)}') |
||||
|
||||
async def send_dkg_pubkey(self, msg_id, pubkey): |
||||
try: |
||||
msg_dict = { |
||||
'msg_id': msg_id, |
||||
'cmd': 'dkg_pubkey', |
||||
'data': pubkey, |
||||
} |
||||
self.sw.write(self.encrypt_msg(msg_dict)) |
||||
await self.sw.drain() |
||||
except Exception as e: |
||||
jlog.error(f'FrostIPCServer.send_dkg_pubkey: {repr(e)}') |
||||
|
||||
async def on_frost_req(self, msg_id, mixdepth, address_type, index, |
||||
sighash): |
||||
try: |
||||
wallet = self.wallet |
||||
client = wallet.client_factory.getClient() |
||||
frost_client = wallet.client_factory.client |
||||
dkg = wallet.dkg |
||||
dkg_session_id = dkg.find_session(mixdepth, address_type, index) |
||||
session_id, _, _ = client.frost_req(dkg_session_id, sighash) |
||||
sig, tweaked_pubkey = await frost_client.wait_on_sig(session_id) |
||||
pubkey = dkg.find_dkg_pubkey(mixdepth, address_type, index) |
||||
await self.send_frost_sig(msg_id, sig, pubkey, tweaked_pubkey) |
||||
except Exception as e: |
||||
jlog.error(f'FrostIPCServer.on_frost_req: {repr(e)}') |
||||
await self.send_frost_sig(msg_id, None, None, None) |
||||
|
||||
async def send_frost_sig(self, msg_id, sig, pubkey, tweaked_pubkey): |
||||
try: |
||||
msg_dict = { |
||||
'msg_id': msg_id, |
||||
'cmd': 'frost_sig', |
||||
'data': (sig, pubkey, tweaked_pubkey), |
||||
} |
||||
self.sw.write(self.encrypt_msg(msg_dict)) |
||||
await self.sw.drain() |
||||
except Exception as e: |
||||
jlog.error(f'FrostIPCServer.send_frost_sig: {repr(e)}') |
||||
|
||||
|
||||
class FrostIPCClient(IPCBase): |
||||
|
||||
def __init__(self, wallet): |
||||
self.loop = asyncio.get_event_loop() |
||||
self.msg_id = 0 |
||||
self.msg_futures = {} |
||||
self.wallet = wallet |
||||
self.pubkey = btc.privkey_to_pubkey(wallet._hostseckey) |
||||
self.sock_path = f'{wallet._storage.get_location()}.sock' |
||||
self.sr = None |
||||
self.sw = None |
||||
|
||||
async def async_init(self): |
||||
try: |
||||
self.sr, self.sw = await asyncio.open_unix_connection( |
||||
self.sock_path) |
||||
self.loop.create_task(self.process_msgs()) |
||||
except (FileNotFoundError, ConnectionRefusedError) as e: |
||||
jmprint('No servefrost socket found. Run wallet-tool.py ' |
||||
'wallet.jmdat servefrost in separate console.', "error") |
||||
twisted_sys_exit(EXIT_FAILURE) |
||||
|
||||
async def process_msgs(self): |
||||
while True: |
||||
try: |
||||
line_data = await self.sr.readline() |
||||
if not line_data: |
||||
if self.sr.at_eof(): |
||||
jlog.info('FrostIPCClient.process_msg: ' |
||||
'client disconnected') |
||||
self.sr = None |
||||
self.sw = None |
||||
for msg_id, fut in list(self.msg_futures.items()): |
||||
fut = self.msg_futures.pop(msg_id) |
||||
fut.cancel() |
||||
break |
||||
else: |
||||
jlog.error('FrostIPCClient.process_msg: ' |
||||
'empty line ignored') |
||||
continue |
||||
enc_bytes = line_data.strip() |
||||
msg_dict = self.decrypt_msg(enc_bytes) |
||||
msg_id = msg_dict['msg_id'] |
||||
cmd = msg_dict['cmd'] |
||||
data = msg_dict['data'] |
||||
if cmd in ['dkg_pubkey', 'frost_sig']: |
||||
await self.on_response(msg_id, data) |
||||
except Exception as e: |
||||
jlog.error(f'FrostIPCClient.process_msgs: {repr(e)}') |
||||
await asyncio.sleep(0.1) |
||||
|
||||
async def on_response(self, msg_id, data): |
||||
fut = self.msg_futures.pop(msg_id, None) |
||||
if fut: |
||||
fut.set_result(data) |
||||
|
||||
async def get_dkg_pubkey(self, mixdepth, address_type, index, |
||||
session_id=None): |
||||
jlog.debug(f'FrostIPCClient.get_dkg_pubkey for mixdepth={mixdepth}, ' |
||||
f'address_type={address_type}, index={index}') |
||||
try: |
||||
self.msg_id += 1 |
||||
msg_dict = { |
||||
'msg_id': self.msg_id, |
||||
'cmd': 'get_dkg_pubkey', |
||||
'data': (mixdepth, address_type, index, session_id), |
||||
} |
||||
self.sw.write(self.encrypt_msg(msg_dict)) |
||||
await self.sw.drain() |
||||
fut = self.loop.create_future() |
||||
self.msg_futures[self.msg_id] = fut |
||||
await fut |
||||
pubkey = fut.result() |
||||
if pubkey is None: |
||||
jlog.error( |
||||
f'FrostIPCClient.get_dkg_pubkey got None pubkey from ' |
||||
f'FrostIPCServer for mixdepth={mixdepth}, ' |
||||
f'address_type={address_type}, index={index}') |
||||
return pubkey |
||||
jlog.debug(f'FrostIPCClient.get_dkg_pubkey successfully got ' |
||||
f'pubkey for mixdepth={mixdepth}, ' |
||||
f'address_type={address_type}, index={index}') |
||||
return pubkey |
||||
except Exception as e: |
||||
jlog.error(f'FrostIPCClient.get_dkg_pubkey: {repr(e)}') |
||||
|
||||
async def frost_req(self, mixdepth, address_type, index, sighash): |
||||
jlog.debug(f'FrostIPCClient.frost_req for mixdepth={mixdepth}, ' |
||||
f'address_type={address_type}, index={index}, ' |
||||
f'sighash={sighash.hex()}') |
||||
try: |
||||
self.msg_id += 1 |
||||
msg_dict = { |
||||
'msg_id': self.msg_id, |
||||
'cmd': 'frost_req', |
||||
'data': (mixdepth, address_type, index, sighash), |
||||
} |
||||
self.sw.write(self.encrypt_msg(msg_dict)) |
||||
await self.sw.drain() |
||||
fut = self.loop.create_future() |
||||
self.msg_futures[self.msg_id] = fut |
||||
await fut |
||||
sig, pubkey, tweaked_pubkey = fut.result() |
||||
if sig is None: |
||||
jlog.error( |
||||
f'FrostIPCClient.frost_req got None sig value from ' |
||||
f'FrostIPCServer for mixdepth={mixdepth}, ' |
||||
f'address_type={address_type}, index={index}, ' |
||||
f'sighash={sighash.hex()}') |
||||
return sig, pubkey, tweaked_pubkey |
||||
jlog.debug( |
||||
f'FrostIPCClient.frost_req successfully got signature ' |
||||
f'for mixdepth={mixdepth}, address_type={address_type}, ' |
||||
f'index={index}, sighash={sighash.hex()}') |
||||
return sig, pubkey, tweaked_pubkey |
||||
except Exception as e: |
||||
jlog.error(f'FrostIPCClient.frost_req: {repr(e)}') |
||||
return None, None, None |
||||
@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
import asyncio |
||||
import sys |
||||
from functools import wraps |
||||
|
||||
from jmbase import stop_reactor |
||||
|
||||
|
||||
def wrap_main(func): |
||||
|
||||
@wraps(func) |
||||
async def func_wrapper(*args, **kwargs): |
||||
try: |
||||
return await func(*args, **kwargs) |
||||
except SystemExit as e: |
||||
return e.args[0] if e.args else None |
||||
finally: |
||||
for task in asyncio.all_tasks(): |
||||
task.cancel() |
||||
stop_reactor() |
||||
|
||||
return func_wrapper |
||||
|
||||
|
||||
def finalize_main_task(main_task): |
||||
if main_task.done(): |
||||
try: |
||||
exit_status = main_task.result() |
||||
if exit_status: |
||||
sys.exit(exit_status) |
||||
except asyncio.CancelledError: |
||||
pass |
||||
except Exception: |
||||
raise |
||||
@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
# chilldkg_ref, secp256k1lab code is from |
||||
# https://github.com/BlockstreamResearch/bip-frost-dkg |
||||
# |
||||
# commit 0f9e4b95b2e1ef4a0d335908e512ddaca60ebd99 |
||||
# Merge: aff38ce 7ddab85 |
||||
# Author: Jonas Nick <jonasd.nick@gmail.com> |
||||
# Date: Thu May 8 07:40:00 2025 +0000 |
||||
# |
||||
# Merge pull request #93 from jonasnick/address-siv-comments |
||||
|
||||
# frost_ref is from |
||||
# https://github.com/siv2r/bip-frost-signing |
||||
# |
||||
#commit f5ea4a5b58c13c234f10b96b620d34e926b49127 |
||||
#Merge: a599e50 8b2eaa9 |
||||
#Author: Sivaram <siv2ram@gmail.com> |
||||
#Date: Sat Jun 21 19:59:11 2025 +0530 |
||||
# |
||||
# Merge pull request #23 from theStack/integrate_secp256k1lab |
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,3 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
__all__ = ["chilldkg"] |
||||
@ -0,0 +1,871 @@
|
||||
"""Reference implementation of ChillDKG. |
||||
|
||||
WARNING: This code is slow and trivially vulnerable to side channel attacks. Do |
||||
not use for anything but tests. |
||||
|
||||
The public API consists of all functions with docstrings, including the types in |
||||
their arguments and return values, and the exceptions they raise; see also the |
||||
`__all__` list. All other definitions are internal. |
||||
""" |
||||
|
||||
from secrets import token_bytes as random_bytes |
||||
from typing import Any, Tuple, List, NamedTuple, NewType, Optional, NoReturn, Dict |
||||
|
||||
from ..secp256k1lab.secp256k1 import Scalar, GE |
||||
from ..secp256k1lab.bip340 import schnorr_sign, schnorr_verify |
||||
from ..secp256k1lab.keys import pubkey_gen_plain |
||||
from ..secp256k1lab.util import bytes_from_int |
||||
|
||||
from .vss import VSSCommitment |
||||
from . import encpedpop |
||||
from .util import ( |
||||
BIP_TAG, |
||||
tagged_hash_bip_dkg, |
||||
ProtocolError, |
||||
FaultyParticipantOrCoordinatorError, |
||||
FaultyCoordinatorError, |
||||
UnknownFaultyParticipantOrCoordinatorError, |
||||
FaultyParticipantError, |
||||
) |
||||
|
||||
__all__ = [ |
||||
# Functions |
||||
"hostpubkey_gen", |
||||
"params_id", |
||||
"participant_step1", |
||||
"participant_step2", |
||||
"participant_finalize", |
||||
"participant_investigate", |
||||
"coordinator_step1", |
||||
"coordinator_finalize", |
||||
"coordinator_investigate", |
||||
"recover", |
||||
# Exceptions |
||||
"InvalidSignatureInCertificateError", |
||||
"HostSeckeyError", |
||||
"SessionParamsError", |
||||
"InvalidHostPubkeyError", |
||||
"DuplicateHostPubkeyError", |
||||
"ThresholdOrCountError", |
||||
"RandomnessError", |
||||
"ProtocolError", |
||||
"FaultyParticipantError", |
||||
"FaultyParticipantOrCoordinatorError", |
||||
"FaultyCoordinatorError", |
||||
"UnknownFaultyParticipantOrCoordinatorError", |
||||
"RecoveryDataError", |
||||
# Types |
||||
"SessionParams", |
||||
"DKGOutput", |
||||
"ParticipantMsg1", |
||||
"ParticipantMsg2", |
||||
"CoordinatorInvestigationMsg", |
||||
"ParticipantState1", |
||||
"ParticipantState2", |
||||
"CoordinatorMsg1", |
||||
"CoordinatorMsg2", |
||||
"CoordinatorState", |
||||
"RecoveryData", |
||||
] |
||||
|
||||
|
||||
### |
||||
### Equality check protocol CertEq |
||||
### |
||||
|
||||
|
||||
def certeq_message(x: bytes, idx: int) -> bytes: |
||||
# Domain separation as described in BIP 340 |
||||
prefix = (BIP_TAG + "certeq message").encode() |
||||
prefix = prefix + b"\x00" * (33 - len(prefix)) |
||||
return prefix + idx.to_bytes(4, "big") + x |
||||
|
||||
|
||||
def certeq_participant_step(hostseckey: bytes, idx: int, x: bytes) -> bytes: |
||||
msg = certeq_message(x, idx) |
||||
return schnorr_sign(msg, hostseckey, aux_rand=random_bytes(32)) |
||||
|
||||
|
||||
def certeq_cert_len(n: int) -> int: |
||||
return 64 * n |
||||
|
||||
|
||||
def certeq_verify(hostpubkeys: List[bytes], x: bytes, cert: bytes) -> None: |
||||
n = len(hostpubkeys) |
||||
if len(cert) != certeq_cert_len(n): |
||||
raise ValueError |
||||
for i in range(n): |
||||
msg = certeq_message(x, i) |
||||
valid = schnorr_verify( |
||||
msg, |
||||
hostpubkeys[i][1:33], |
||||
cert[i * 64 : (i + 1) * 64], |
||||
) |
||||
if not valid: |
||||
raise InvalidSignatureInCertificateError(i) |
||||
|
||||
|
||||
def certeq_coordinator_step(sigs: List[bytes]) -> bytes: |
||||
cert = b"".join(sigs) |
||||
return cert |
||||
|
||||
|
||||
class InvalidSignatureInCertificateError(ValueError): |
||||
def __init__(self, participant: int, *args: Any): |
||||
self.participant = participant |
||||
super().__init__(participant, *args) |
||||
|
||||
|
||||
### |
||||
### Host keys |
||||
### |
||||
|
||||
|
||||
def hostpubkey_gen(hostseckey: bytes) -> bytes: |
||||
"""Compute the participant's host public key from the host secret key. |
||||
|
||||
The host public key is the long-term cryptographic identity of the |
||||
participant. |
||||
|
||||
This function interprets `hostseckey` as big-endian integer, and computes |
||||
the corresponding "plain" public key in compressed serialization (33 bytes, |
||||
starting with 0x02 or 0x03). This is the key generation procedure |
||||
traditionally used in Bitcoin, e.g., for ECDSA. In other words, this |
||||
function is equivalent to `IndividualPubkey` as defined in |
||||
[[BIP 327](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki#key-generation-of-an-individual-signer)]. |
||||
TODO Refer to the FROST signing BIP instead, once that one has a number. |
||||
|
||||
Arguments: |
||||
hostseckey: This participant's long-term secret key (32 bytes). |
||||
The key **must** be 32 bytes of cryptographically secure randomness |
||||
with sufficient entropy to be unpredictable. All outputs of a |
||||
successful participant in a session can be recovered from (a backup |
||||
of) the key and per-session recovery data. |
||||
|
||||
The same host secret key (and thus the same host public key) can be |
||||
used in multiple DKG sessions. A host public key can be correlated |
||||
to the threshold public key resulting from a DKG session only by |
||||
parties who observed the session, namely the participants, the |
||||
coordinator (and any eavesdropper). |
||||
|
||||
Returns: |
||||
The host public key (33 bytes). |
||||
|
||||
Raises: |
||||
HostSeckeyError: If the length of `hostseckey` is not 32 bytes or if the |
||||
key is invalid. |
||||
""" |
||||
if len(hostseckey) != 32: |
||||
raise HostSeckeyError |
||||
|
||||
try: |
||||
return pubkey_gen_plain(hostseckey) |
||||
except ValueError: |
||||
raise HostSeckeyError |
||||
|
||||
|
||||
class HostSeckeyError(ValueError): |
||||
"""Raised if the host secret key is invalid. |
||||
|
||||
This incluces the case that its length is not 32 bytes.""" |
||||
|
||||
|
||||
### |
||||
### Session input and outputs |
||||
### |
||||
|
||||
|
||||
# It would be more idiomatic Python to make this a real (data)class, perform |
||||
# data validation in the constructor, and add methods to it, but let's stick to |
||||
# simple tuples in the public API in order to keep it approachable to readers |
||||
# who are not too familiar with Python. |
||||
class SessionParams(NamedTuple): |
||||
"""A `SessionParams` tuple holds the common parameters of a DKG session. |
||||
|
||||
Attributes: |
||||
hostpubkeys: Ordered list of the host public keys of all participants. |
||||
t: The participation threshold `t`. |
||||
This is the number of participants that will be required to sign. |
||||
It must hold that `1 <= t <= len(hostpubkeys) <= 2**32 - 1`. |
||||
|
||||
Participants **must** ensure that they have obtained authentic host |
||||
public keys of all the other participants in the session to make |
||||
sure that they run the DKG and generate a threshold public key with |
||||
the intended set of participants. This is analogous to traditional |
||||
threshold signatures (known as "multisig" in the Bitcoin community), |
||||
[[BIP 383](https://github.com/bitcoin/bips/blob/master/bip-0383.mediawiki)], |
||||
where the participants need to obtain authentic extended public keys |
||||
("xpubs") from the other participants to generate multisig |
||||
addresses, or MuSig2 |
||||
[[BIP 327](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki)], |
||||
where the participants need to obtain authentic individual public |
||||
keys of the other participants to generate an aggregated public key. |
||||
|
||||
A DKG session will fail if the participants and the coordinator in a session |
||||
don't have the `hostpubkeys` in the same order. This will make sure that |
||||
honest participants agree on the order as part of the session, which is |
||||
useful if the order carries an implicit meaning in the application (e.g., if |
||||
the first `t` participants are the primary participants for signing and the |
||||
others are fallback participants). If there is no canonical order of the |
||||
participants in the application, the caller can sort the list of host public |
||||
keys with the [KeySort algorithm specified in |
||||
BIP 327](https://github.com/bitcoin/bips/blob/master/bip-0327.mediawiki#key-sorting) |
||||
to abstract away from the order. |
||||
""" |
||||
|
||||
hostpubkeys: List[bytes] |
||||
t: int |
||||
|
||||
|
||||
def params_validate(params: SessionParams) -> None: |
||||
(hostpubkeys, t) = params |
||||
|
||||
if not (1 <= t <= len(hostpubkeys) <= 2**32 - 1): |
||||
raise ThresholdOrCountError |
||||
|
||||
# Check that all hostpubkeys are valid |
||||
for i, hostpubkey in enumerate(hostpubkeys): |
||||
try: |
||||
_ = GE.from_bytes_compressed(hostpubkey) |
||||
except ValueError as e: |
||||
raise InvalidHostPubkeyError(i) from e |
||||
|
||||
# Check for duplicate hostpubkeys and find the corresponding indices |
||||
hostpubkey_to_idx: Dict[bytes, int] = dict() |
||||
for i, hostpubkey in enumerate(hostpubkeys): |
||||
if hostpubkey in hostpubkey_to_idx: |
||||
raise DuplicateHostPubkeyError(hostpubkey_to_idx[hostpubkey], i) |
||||
hostpubkey_to_idx[hostpubkey] = i |
||||
|
||||
|
||||
def params_id(params: SessionParams) -> bytes: |
||||
"""Return the parameters ID, a unique representation of the `SessionParams`. |
||||
|
||||
In the common scenario that the participants obtain host public keys from |
||||
the other participants over channels that do not provide end-to-end |
||||
authentication of the sending participant (e.g., if the participants simply |
||||
send their unauthenticated host public keys to the coordinator, who is |
||||
supposed to relay them to all participants), the parameters ID serves as a |
||||
convenient way to perform an out-of-band comparison of all host public keys. |
||||
It is a collision-resistant cryptographic hash of the `SessionParams` |
||||
tuple. As a result, if all participants have obtained an identical |
||||
parameters ID (as can be verified out-of-band), then they all agree on all |
||||
host public keys and the threshold `t`, and in particular, all participants |
||||
have obtained authentic public host keys. |
||||
|
||||
Returns: |
||||
bytes: The parameters ID, a 32-byte string. |
||||
|
||||
Raises: |
||||
InvalidHostPubkeyError: If `hostpubkeys` contains an invalid public key. |
||||
DuplicateHostPubkeyError: If `hostpubkeys` contains duplicates. |
||||
ThresholdOrCountError: If `1 <= t <= len(hostpubkeys) <= 2**32 - 1` does |
||||
not hold. |
||||
""" |
||||
params_validate(params) |
||||
hostpubkeys, t = params |
||||
|
||||
t_bytes = t.to_bytes(4, byteorder="big") |
||||
params_id = tagged_hash_bip_dkg( |
||||
"params_id", |
||||
t_bytes + b"".join(hostpubkeys), |
||||
) |
||||
assert len(params_id) == 32 |
||||
return params_id |
||||
|
||||
|
||||
class SessionParamsError(ValueError): |
||||
"""Base exception for invalid `SessionParams` tuples.""" |
||||
|
||||
|
||||
class DuplicateHostPubkeyError(SessionParamsError): |
||||
"""Raised if two participants have identical host public keys. |
||||
|
||||
This exception is raised when two participants have an identical host public |
||||
key in the `SessionParams` tuple. Assuming the host public keys in question |
||||
have been transmitted correctly, this exception implies that at least one of |
||||
the two participants is faulty (because duplicates occur only with |
||||
negligible probability if keys are generated honestly). |
||||
|
||||
Attributes: |
||||
participant1 (int): Index of the first participant. |
||||
participant2 (int): Index of the second participant. |
||||
""" |
||||
|
||||
def __init__(self, participant1: int, participant2: int, *args: Any): |
||||
self.participant1 = participant1 |
||||
self.participant2 = participant2 |
||||
super().__init__(participant1, participant2, *args) |
||||
|
||||
|
||||
class InvalidHostPubkeyError(SessionParamsError): |
||||
"""Raised if a host public key is invalid. |
||||
|
||||
This exception is raised when a host public key in the `SessionParams` tuple |
||||
is not a valid public key in compressed serialization. Assuming the host |
||||
public keys in question has been transmitted correctly, this exception |
||||
implies that the corresponding participant is faulty. |
||||
|
||||
Attributes: |
||||
participant (int): Index of the participant. |
||||
""" |
||||
|
||||
def __init__(self, participant: int, *args: Any): |
||||
self.participant = participant |
||||
super().__init__(participant, *args) |
||||
|
||||
|
||||
class ThresholdOrCountError(SessionParamsError): |
||||
"""Raised if `1 <= t <= len(hostpubkeys) <= 2**32 - 1` does not hold.""" |
||||
|
||||
|
||||
# This is really the same definition as in simplpedpop and encpedpop. We repeat |
||||
# it here only to have its docstring in this module. |
||||
class DKGOutput(NamedTuple): |
||||
"""Holds the outputs of a DKG session. |
||||
|
||||
Attributes: |
||||
secshare: Secret share of the participant (or `None` for coordinator) |
||||
threshold_pubkey: Generated threshold public key representing the group |
||||
pubshares: Public shares of the participants |
||||
""" |
||||
|
||||
secshare: Optional[bytes] |
||||
threshold_pubkey: bytes |
||||
pubshares: List[bytes] |
||||
|
||||
|
||||
RecoveryData = NewType("RecoveryData", bytes) |
||||
|
||||
|
||||
### |
||||
### Messages |
||||
### |
||||
|
||||
|
||||
class ParticipantMsg1(NamedTuple): |
||||
enc_pmsg: encpedpop.ParticipantMsg |
||||
|
||||
|
||||
class ParticipantMsg2(NamedTuple): |
||||
sig: bytes |
||||
|
||||
|
||||
class CoordinatorMsg1(NamedTuple): |
||||
enc_cmsg: encpedpop.CoordinatorMsg |
||||
enc_secshares: List[Scalar] |
||||
|
||||
|
||||
class CoordinatorMsg2(NamedTuple): |
||||
cert: bytes |
||||
|
||||
|
||||
class CoordinatorInvestigationMsg(NamedTuple): |
||||
enc_cinv: encpedpop.CoordinatorInvestigationMsg |
||||
|
||||
|
||||
def deserialize_recovery_data( |
||||
b: bytes, |
||||
) -> Tuple[int, VSSCommitment, List[bytes], List[bytes], List[Scalar], bytes]: |
||||
rest = b |
||||
|
||||
# Read t (4 bytes) |
||||
if len(rest) < 4: |
||||
raise ValueError |
||||
t, rest = int.from_bytes(rest[:4], byteorder="big"), rest[4:] |
||||
|
||||
# Read sum_coms (33*t bytes) |
||||
if len(rest) < 33 * t: |
||||
raise ValueError |
||||
sum_coms, rest = ( |
||||
VSSCommitment.from_bytes_and_t(rest[: 33 * t], t), |
||||
rest[33 * t :], |
||||
) |
||||
|
||||
# Compute n |
||||
n, remainder = divmod(len(rest), (33 + 33 + 32 + 64)) |
||||
if remainder != 0: |
||||
raise ValueError |
||||
|
||||
# Read hostpubkeys (33*n bytes) |
||||
if len(rest) < 33 * n: |
||||
raise ValueError |
||||
hostpubkeys, rest = [rest[i : i + 33] for i in range(0, 33 * n, 33)], rest[33 * n :] |
||||
|
||||
# Read pubnonces (33*n bytes) |
||||
if len(rest) < 33 * n: |
||||
raise ValueError |
||||
pubnonces, rest = [rest[i : i + 33] for i in range(0, 33 * n, 33)], rest[33 * n :] |
||||
|
||||
# Read enc_secshares (32*n bytes) |
||||
if len(rest) < 32 * n: |
||||
raise ValueError |
||||
enc_secshares, rest = ( |
||||
[Scalar.from_bytes_checked(rest[i : i + 32]) for i in range(0, 32 * n, 32)], |
||||
rest[32 * n :], |
||||
) |
||||
|
||||
# Read cert |
||||
cert_len = certeq_cert_len(n) |
||||
if len(rest) < cert_len: |
||||
raise ValueError |
||||
cert, rest = rest[:cert_len], rest[cert_len:] |
||||
|
||||
if len(rest) != 0: |
||||
raise ValueError |
||||
return (t, sum_coms, hostpubkeys, pubnonces, enc_secshares, cert) |
||||
|
||||
|
||||
### |
||||
### Participant |
||||
### |
||||
|
||||
|
||||
class ParticipantState1(NamedTuple): |
||||
params: SessionParams |
||||
idx: int |
||||
enc_state: encpedpop.ParticipantState |
||||
|
||||
|
||||
class ParticipantState2(NamedTuple): |
||||
params: SessionParams |
||||
eq_input: bytes |
||||
dkg_output: DKGOutput |
||||
|
||||
|
||||
def participant_step1( |
||||
hostseckey: bytes, params: SessionParams, random: bytes |
||||
) -> Tuple[ParticipantState1, ParticipantMsg1]: |
||||
"""Perform a participant's first step of a ChillDKG session. |
||||
|
||||
Arguments: |
||||
hostseckey: Participant's long-term host secret key (32 bytes). |
||||
params: Common session parameters. |
||||
random: FRESH random byte string (32 bytes). |
||||
|
||||
Returns: |
||||
ParticipantState1: The participant's session state after this step, to |
||||
be passed as an argument to `participant_step2`. The state **must |
||||
not** be reused (i.e., it must be passed only to one |
||||
`participant_step2` call). |
||||
ParticipantMsg1: The first message to be sent to the coordinator. |
||||
|
||||
Raises: |
||||
HostSeckeyError: If the length of `hostseckey` is not 32 bytes, if the |
||||
key is invalid, or if the key does not match any entry of |
||||
`hostpubkeys`. |
||||
InvalidHostPubkeyError: If `hostpubkeys` contains an invalid public key. |
||||
DuplicateHostPubkeyError: If `hostpubkeys` contains duplicates. |
||||
ThresholdOrCountError: If `1 <= t <= len(hostpubkeys) <= 2**32 - 1` does |
||||
not hold. |
||||
RandomnessError: If the length of `random` is not 32 bytes. |
||||
""" |
||||
hostpubkey = hostpubkey_gen(hostseckey) # HostSeckeyError if len(hostseckey) != 32 |
||||
|
||||
params_validate(params) |
||||
(hostpubkeys, t) = params |
||||
|
||||
try: |
||||
idx = hostpubkeys.index(hostpubkey) |
||||
except ValueError as e: |
||||
raise HostSeckeyError( |
||||
"Host secret key does not match any host public key" |
||||
) from e |
||||
if len(random) != 32: |
||||
raise RandomnessError |
||||
|
||||
enc_state, enc_pmsg = encpedpop.participant_step1( |
||||
# We know that EncPedPop uses its seed only by feeding it to a hash |
||||
# function. Thus, it is sufficient that the seed has a high entropy, |
||||
# and so we can simply pass the hostseckey as seed. |
||||
seed=hostseckey, |
||||
deckey=hostseckey, |
||||
t=t, |
||||
# This requires the joint security of Schnorr signatures and ECDH. |
||||
enckeys=hostpubkeys, |
||||
idx=idx, |
||||
random=random, |
||||
) # HostSeckeyError if len(hostseckey) != 32 |
||||
state1 = ParticipantState1(params, idx, enc_state) |
||||
return state1, ParticipantMsg1(enc_pmsg) |
||||
|
||||
|
||||
class RandomnessError(ValueError): |
||||
"""Raised if the length of the provided randomness is not 32 bytes.""" |
||||
|
||||
|
||||
def participant_step2( |
||||
hostseckey: bytes, |
||||
state1: ParticipantState1, |
||||
cmsg1: CoordinatorMsg1, |
||||
) -> Tuple[ParticipantState2, ParticipantMsg2]: |
||||
"""Perform a participant's second step of a ChillDKG session. |
||||
|
||||
**Warning:** |
||||
After sending the returned message to the coordinator, this participant |
||||
**must not** erase the hostseckey, even if this participant does not receive |
||||
the coordinator reply needed for the `participant_finalize` call. The |
||||
underlying reason is that some other participant may receive the coordinator |
||||
reply, deem the DKG session successful and use the resulting threshold |
||||
public key (e.g., by sending funds to it). If the coordinator reply remains |
||||
missing, that other participant can, at any point in the future, convince |
||||
this participant of the success of the DKG session by presenting recovery |
||||
data, from which this participant can recover the DKG output using the |
||||
`recover` function. |
||||
|
||||
Arguments: |
||||
hostseckey: Participant's long-term host secret key (32 bytes). |
||||
state1: The participant's session state as output by |
||||
`participant_step1`. |
||||
cmsg1: The first message received from the coordinator. |
||||
|
||||
Returns: |
||||
ParticipantState2: The participant's session state after this step, to |
||||
be passed as an argument to `participant_finalize`. The state **must |
||||
not** be reused (i.e., it must be passed only to one |
||||
`participant_finalize` call). |
||||
ParticipantMsg2: The second message to be sent to the coordinator. |
||||
|
||||
Raises: |
||||
HostSeckeyError: If the length of `hostseckey` is not 32 bytes. |
||||
FaultyCoordinatorError: If the coordinator is faulty. See the |
||||
documentation of the exception for further details. |
||||
FaultyParticipantOrCoordinatorError: If another known participant or the |
||||
coordinator is faulty. See the documentation of the exception for |
||||
further details. |
||||
UnknownFaultyParticipantOrCoordinatorError: If another unknown |
||||
participant or the coordinator is faulty, but running the optional |
||||
investigation procedure of the protocol is necessary to determine a |
||||
suspected participant. See the documentation of the exception for |
||||
further details. |
||||
""" |
||||
if len(hostseckey) != 32: |
||||
raise HostSeckeyError |
||||
|
||||
params, idx, enc_state = state1 |
||||
enc_cmsg, enc_secshares = cmsg1 |
||||
|
||||
enc_dkg_output, eq_input = encpedpop.participant_step2( |
||||
state=enc_state, |
||||
deckey=hostseckey, |
||||
cmsg=enc_cmsg, |
||||
enc_secshare=enc_secshares[idx], |
||||
) |
||||
|
||||
# Include the enc_shares in eq_input to ensure that participants agree on |
||||
# all shares, which in turn ensures that they have the right recovery data. |
||||
eq_input += b"".join([bytes_from_int(int(share)) for share in enc_secshares]) |
||||
dkg_output = DKGOutput._make(enc_dkg_output) |
||||
state2 = ParticipantState2(params, eq_input, dkg_output) |
||||
sig = certeq_participant_step(hostseckey, idx, eq_input) |
||||
pmsg2 = ParticipantMsg2(sig) |
||||
return state2, pmsg2 |
||||
|
||||
|
||||
def participant_finalize( |
||||
state2: ParticipantState2, cmsg2: CoordinatorMsg2 |
||||
) -> Tuple[DKGOutput, RecoveryData]: |
||||
"""Perform a participant's final step of a ChillDKG session. |
||||
|
||||
If this function returns properly (without an exception), then this |
||||
participant deems the DKG session successful. It is, however, possible that |
||||
other participants have received a `cmsg2` from the coordinator that made |
||||
them raise an exception instead, or that they have not received a `cmsg2` |
||||
from the coordinator at all. These participants can, at any point in time in |
||||
the future (e.g., when initiating a signing session), be convinced to deem |
||||
the session successful by presenting the recovery data to them, from which |
||||
they can recover the DKG outputs using the `recover` function. |
||||
|
||||
**Warning:** |
||||
Changing perspectives, this implies that, even when obtaining an exception, |
||||
this participant **must not** conclude that the DKG session has failed, and |
||||
as a consequence, this particiant **must not** erase the hostseckey. The |
||||
underlying reason is that some other participant may deem the DKG session |
||||
successful and use the resulting threshold public key (e.g., by sending |
||||
funds to it). That other participant can, at any point in the future, |
||||
convince this participant of the success of the DKG session by presenting |
||||
recovery data to this participant. |
||||
|
||||
Arguments: |
||||
state2: The participant's state as output by `participant_step2`. |
||||
cmsg2: The second message received from the coordinator. |
||||
|
||||
Returns: |
||||
DKGOutput: The DKG output. |
||||
bytes: The serialized recovery data. |
||||
|
||||
Raises: |
||||
FaultyParticipantOrCoordinatorError: If another known participant or the |
||||
coordinator is faulty. Make sure to read the above warning, and see |
||||
the documentation of the exception for further details. |
||||
FaultyCoordinatorError: If the coordinator is faulty. Make sure to read |
||||
the above warning, and see the documentation of the exception for |
||||
further details. |
||||
""" |
||||
params, eq_input, dkg_output = state2 |
||||
try: |
||||
certeq_verify(params.hostpubkeys, eq_input, cmsg2.cert) |
||||
except InvalidSignatureInCertificateError as e: |
||||
raise FaultyParticipantOrCoordinatorError( |
||||
e.participant, |
||||
"Participant has provided an invalid signature for the certificate", |
||||
) from e |
||||
return dkg_output, RecoveryData(eq_input + cmsg2.cert) |
||||
|
||||
|
||||
def participant_investigate( |
||||
error: UnknownFaultyParticipantOrCoordinatorError, |
||||
cinv: CoordinatorInvestigationMsg, |
||||
) -> NoReturn: |
||||
"""Investigate who is to blame for a failed ChillDKG session. |
||||
|
||||
This function can optionally be called when `participant_step2` raises |
||||
`UnknownFaultyParticipantOrCoordinatorError`. It narrows down the suspected |
||||
faulty parties by analyzing the investigation message provided by the coordinator. |
||||
|
||||
This function does not return normally. Instead, it raises one of two |
||||
exceptions. |
||||
|
||||
Arguments: |
||||
error: `UnknownFaultyParticipantOrCoordinatorError` raised by |
||||
`participant_step2`. |
||||
cinv: Coordinator investigation message for this participant as output |
||||
by `coordinator_investigate`. |
||||
|
||||
Raises: |
||||
FaultyParticipantOrCoordinatorError: If another known participant or the |
||||
coordinator is faulty. See the documentation of the exception for |
||||
further details. |
||||
FaultyCoordinatorError: If the coordinator is faulty. See the |
||||
documentation of the exception for further details. |
||||
""" |
||||
assert isinstance(error.inv_data, encpedpop.ParticipantInvestigationData) |
||||
encpedpop.participant_investigate( |
||||
error=error, |
||||
cinv=cinv.enc_cinv, |
||||
) |
||||
|
||||
|
||||
### |
||||
### Coordinator |
||||
### |
||||
|
||||
|
||||
class CoordinatorState(NamedTuple): |
||||
params: SessionParams |
||||
eq_input: bytes |
||||
dkg_output: DKGOutput |
||||
|
||||
|
||||
def coordinator_step1( |
||||
pmsgs1: List[ParticipantMsg1], params: SessionParams |
||||
) -> Tuple[CoordinatorState, CoordinatorMsg1]: |
||||
"""Perform the coordinator's first step of a ChillDKG session. |
||||
|
||||
Arguments: |
||||
pmsgs1: List of first messages received from the participants. The |
||||
list's length must equal the total number of participants. |
||||
params: Common session parameters. |
||||
|
||||
Returns: |
||||
CoordinatorState: The coordinator's session state after this step, to be |
||||
passed as an argument to `coordinator_finalize`. The state is not |
||||
supposed to be reused (i.e., it should be passed only to one |
||||
`coordinator_finalize` call). |
||||
CoordinatorMsg1: The first message to be sent to all participants. |
||||
|
||||
Raises: |
||||
InvalidHostPubkeyError: If `hostpubkeys` contains an invalid public key. |
||||
DuplicateHostPubkeyError: If `hostpubkeys` contains duplicates. |
||||
ThresholdOrCountError: If `1 <= t <= len(hostpubkeys) <= 2**32 - 1` does |
||||
not hold. |
||||
FaultyParticipantError: If another participant is faulty. See the |
||||
documentation of the exception for further details. |
||||
""" |
||||
params_validate(params) |
||||
hostpubkeys, t = params |
||||
|
||||
enc_cmsg, enc_dkg_output, eq_input, enc_secshares = encpedpop.coordinator_step( |
||||
pmsgs=[pmsg1.enc_pmsg for pmsg1 in pmsgs1], |
||||
t=t, |
||||
enckeys=hostpubkeys, |
||||
) |
||||
eq_input += b"".join([bytes_from_int(int(share)) for share in enc_secshares]) |
||||
dkg_output = DKGOutput._make(enc_dkg_output) # Convert to chilldkg.DKGOutput type |
||||
state = CoordinatorState(params, eq_input, dkg_output) |
||||
cmsg1 = CoordinatorMsg1(enc_cmsg, enc_secshares) |
||||
return state, cmsg1 |
||||
|
||||
|
||||
def coordinator_finalize( |
||||
state: CoordinatorState, pmsgs2: List[ParticipantMsg2] |
||||
) -> Tuple[CoordinatorMsg2, DKGOutput, RecoveryData]: |
||||
"""Perform the coordinator's final step of a ChillDKG session. |
||||
|
||||
If this function returns properly (without an exception), then the |
||||
coordinator deems the DKG session successful. The returned `CoordinatorMsg2` |
||||
is supposed to be sent to all participants, who are supposed to pass it as |
||||
input to the `participant_finalize` function. It is, however, possible that |
||||
some participants pass a wrong and invalid message to `participant_finalize` |
||||
(e.g., because the message is transmitted incorrectly). These participants |
||||
can, at any point in time in the future (e.g., when initiating a signing |
||||
session), be convinced to deem the session successful by presenting the |
||||
recovery data to them, from which they can recover the DKG outputs using the |
||||
`recover` function. |
||||
|
||||
If this function raises an exception, then the DKG session was not |
||||
successful from the perspective of the coordinator. In this case, it is, in |
||||
principle, possible to recover the DKG outputs of the coordinator using the |
||||
recovery data from a successful participant, should one exist. Any such |
||||
successful participant is either faulty, or has received messages from |
||||
other participants via a communication channel beside the coordinator. |
||||
|
||||
Arguments: |
||||
state: The coordinator's session state as output by `coordinator_step1`. |
||||
pmsgs2: List of second messages received from the participants. The |
||||
list's length must equal the total number of participants. |
||||
|
||||
Returns: |
||||
CoordinatorMsg2: The second message to be sent to all participants. |
||||
DKGOutput: The DKG output. Since the coordinator does not have a secret |
||||
share, the DKG output will have the `secshare` field set to `None`. |
||||
bytes: The serialized recovery data. |
||||
|
||||
Raises: |
||||
FaultyParticipantError: If another participant is faulty. See the |
||||
documentation of the exception for further details. |
||||
""" |
||||
params, eq_input, dkg_output = state |
||||
if len(pmsgs2) != len(params.hostpubkeys): |
||||
raise ValueError |
||||
|
||||
cert = certeq_coordinator_step([pmsg2.sig for pmsg2 in pmsgs2]) |
||||
try: |
||||
certeq_verify(params.hostpubkeys, eq_input, cert) |
||||
except InvalidSignatureInCertificateError as e: |
||||
raise FaultyParticipantError( |
||||
e.participant, |
||||
"Participant has provided an invalid signature for the certificate", |
||||
) from e |
||||
return CoordinatorMsg2(cert), dkg_output, RecoveryData(eq_input + cert) |
||||
|
||||
|
||||
def coordinator_investigate( |
||||
pmsgs: List[ParticipantMsg1], |
||||
) -> List[CoordinatorInvestigationMsg]: |
||||
"""Generate investigation messages for a ChillDKG session. |
||||
|
||||
The investigation messages will allow the participants to investigate who is |
||||
to blame for a failed ChillDKG session (see `participant_investigate`). |
||||
|
||||
Each message is intended for a single participant but can be safely |
||||
broadcast to all participants because the messages contain no confidential |
||||
information. |
||||
|
||||
Arguments: |
||||
pmsgs: List of first messages received from the participants. |
||||
|
||||
Returns: |
||||
List[CoordinatorInvestigationMsg]: A list of investigation messages, each |
||||
intended for a single participant. |
||||
""" |
||||
enc_cinvs = encpedpop.coordinator_investigate([pmsg.enc_pmsg for pmsg in pmsgs]) |
||||
return [CoordinatorInvestigationMsg(enc_cinv) for enc_cinv in enc_cinvs] |
||||
|
||||
|
||||
### |
||||
### Recovery |
||||
### |
||||
|
||||
|
||||
def recover( |
||||
hostseckey: Optional[bytes], recovery_data: RecoveryData |
||||
) -> Tuple[DKGOutput, SessionParams]: |
||||
"""Recover the DKG output of a ChillDKG session. |
||||
|
||||
This function serves two different purposes: |
||||
1. To recover from an exception in `participant_finalize` or |
||||
`coordinator_finalize`, after obtaining the recovery data from another |
||||
participant or the coordinator. See `participant_finalize` and |
||||
`coordinator_finalize` for background. |
||||
2. To reproduce the DKG outputs on a new device, e.g., to recover from a |
||||
backup after data loss. |
||||
|
||||
Arguments: |
||||
hostseckey: This participant's long-term host secret key (32 bytes) or |
||||
`None` if recovering the coordinator. |
||||
recovery_data: Recovery data from a successful session. |
||||
|
||||
Returns: |
||||
DKGOutput: The recovered DKG output. |
||||
SessionParams: The common parameters of the recovered session. |
||||
|
||||
Raises: |
||||
HostSeckeyError: If the length of `hostseckey` is not 32 bytes, if the |
||||
key is invalid, or if the key does not match the recovery data. |
||||
(This can also occur if the recovery data is invalid.) |
||||
RecoveryDataError: If recovery failed due to invalid recovery data. |
||||
""" |
||||
try: |
||||
(t, sum_coms, hostpubkeys, pubnonces, enc_secshares, cert) = ( |
||||
deserialize_recovery_data(recovery_data) |
||||
) |
||||
except Exception as e: |
||||
raise RecoveryDataError("Failed to deserialize recovery data") from e |
||||
|
||||
n = len(hostpubkeys) |
||||
params = SessionParams(hostpubkeys, t) |
||||
try: |
||||
params_validate(params) |
||||
except SessionParamsError as e: |
||||
raise RecoveryDataError("Invalid session parameters in recovery data") from e |
||||
|
||||
# Verify cert |
||||
eq_input = recovery_data[: -len(cert)] |
||||
try: |
||||
certeq_verify(hostpubkeys, eq_input, cert) |
||||
except InvalidSignatureInCertificateError as e: |
||||
raise RecoveryDataError("Invalid certificate in recovery data") from e |
||||
|
||||
# Compute threshold pubkey and individual pubshares |
||||
sum_coms, tweak, _ = sum_coms.invalid_taproot_commit() |
||||
threshold_pubkey = sum_coms.commitment_to_secret() |
||||
pubshares = [sum_coms.pubshare(i) for i in range(n)] |
||||
|
||||
if hostseckey: |
||||
hostpubkey = hostpubkey_gen(hostseckey) # HostSeckeyError |
||||
try: |
||||
idx = hostpubkeys.index(hostpubkey) |
||||
except ValueError as e: |
||||
raise HostSeckeyError( |
||||
"Host secret key does not match any host public key in the recovery data" |
||||
) from e |
||||
|
||||
# Decrypt share |
||||
enc_context = encpedpop.serialize_enc_context(t, hostpubkeys) |
||||
secshare = encpedpop.decrypt_sum( |
||||
hostseckey, |
||||
hostpubkeys[idx], |
||||
pubnonces, |
||||
enc_context, |
||||
idx, |
||||
enc_secshares[idx], |
||||
) |
||||
secshare_tweaked = secshare + tweak |
||||
|
||||
# This is just a sanity check. Our signature is valid, so we have done |
||||
# an equivalent check already during the actual session. |
||||
assert VSSCommitment.verify_secshare(secshare_tweaked, pubshares[idx]) |
||||
else: |
||||
secshare_tweaked = None |
||||
|
||||
dkg_output = DKGOutput( |
||||
None if secshare_tweaked is None else secshare_tweaked.to_bytes(), |
||||
threshold_pubkey.to_bytes_compressed(), |
||||
[pubshare.to_bytes_compressed() for pubshare in pubshares], |
||||
) |
||||
return dkg_output, params |
||||
|
||||
|
||||
class RecoveryDataError(ValueError): |
||||
"""Raised if the recovery data is invalid.""" |
||||
@ -0,0 +1,341 @@
|
||||
from typing import Tuple, List, NamedTuple, NoReturn |
||||
|
||||
from ..secp256k1lab.secp256k1 import Scalar, GE |
||||
from ..secp256k1lab.ecdh import ecdh_libsecp256k1 |
||||
from ..secp256k1lab.keys import pubkey_gen_plain |
||||
|
||||
from . import simplpedpop |
||||
from .util import ( |
||||
UnknownFaultyParticipantOrCoordinatorError, |
||||
tagged_hash_bip_dkg, |
||||
FaultyParticipantError, |
||||
FaultyCoordinatorError, |
||||
) |
||||
|
||||
|
||||
### |
||||
### Encryption |
||||
### |
||||
|
||||
|
||||
def ecdh( |
||||
seckey: bytes, my_pubkey: bytes, their_pubkey: bytes, context: bytes, sending: bool |
||||
) -> Scalar: |
||||
data = ecdh_libsecp256k1(seckey, their_pubkey) |
||||
if sending: |
||||
data += my_pubkey + their_pubkey |
||||
else: |
||||
data += their_pubkey + my_pubkey |
||||
assert len(data) == 32 + 2 * 33 |
||||
data += context |
||||
ret: Scalar = Scalar.from_bytes_wrapping( |
||||
tagged_hash_bip_dkg("encpedpop ecdh", data) |
||||
) |
||||
return ret |
||||
|
||||
|
||||
def self_pad(symkey: bytes, nonce: bytes, context: bytes) -> Scalar: |
||||
# Pad for symmetric encryption to ourselves |
||||
pad: Scalar = Scalar.from_bytes_wrapping( |
||||
tagged_hash_bip_dkg("encaps_multi self_pad", symkey + nonce + context) |
||||
) |
||||
return pad |
||||
|
||||
|
||||
def encaps_multi( |
||||
secnonce: bytes, |
||||
pubnonce: bytes, |
||||
deckey: bytes, |
||||
enckeys: List[bytes], |
||||
context: bytes, |
||||
idx: int, |
||||
) -> List[Scalar]: |
||||
# This is effectively the "Hashed ElGamal" multi-recipient KEM described in |
||||
# Section 5 of "Multi-recipient encryption, revisited" by Alexandre Pinto, |
||||
# Bertram Poettering, Jacob C. N. Schuldt (AsiaCCS 2014). Its crucial |
||||
# feature is to feed the index of the enckey to the hash function. The only |
||||
# difference is that we feed also the pubnonce and context data into the |
||||
# hash function. |
||||
pads = [] |
||||
for i, enckey in enumerate(enckeys): |
||||
context_ = i.to_bytes(4, byteorder="big") + context |
||||
if i == idx: |
||||
# We're encrypting to ourselves, so we use a symmetrically derived |
||||
# pad to save the ECDH computation. |
||||
pad = self_pad(symkey=deckey, nonce=pubnonce, context=context_) |
||||
else: |
||||
pad = ecdh( |
||||
seckey=secnonce, |
||||
my_pubkey=pubnonce, |
||||
their_pubkey=enckey, |
||||
context=context_, |
||||
sending=True, |
||||
) |
||||
pads.append(pad) |
||||
return pads |
||||
|
||||
|
||||
def encrypt_multi( |
||||
secnonce: bytes, |
||||
pubnonce: bytes, |
||||
deckey: bytes, |
||||
enckeys: List[bytes], |
||||
context: bytes, |
||||
idx: int, |
||||
plaintexts: List[Scalar], |
||||
) -> List[Scalar]: |
||||
pads = encaps_multi(secnonce, pubnonce, deckey, enckeys, context, idx) |
||||
if len(plaintexts) != len(pads): |
||||
raise ValueError |
||||
ciphertexts = [plaintext + pad for plaintext, pad in zip(plaintexts, pads)] |
||||
return ciphertexts |
||||
|
||||
|
||||
def decaps_multi( |
||||
deckey: bytes, |
||||
enckey: bytes, |
||||
pubnonces: List[bytes], |
||||
context: bytes, |
||||
idx: int, |
||||
) -> List[Scalar]: |
||||
context_ = idx.to_bytes(4, byteorder="big") + context |
||||
pads = [] |
||||
for sender_idx, pubnonce in enumerate(pubnonces): |
||||
if sender_idx == idx: |
||||
pad = self_pad(symkey=deckey, nonce=pubnonce, context=context_) |
||||
else: |
||||
pad = ecdh( |
||||
seckey=deckey, |
||||
my_pubkey=enckey, |
||||
their_pubkey=pubnonce, |
||||
context=context_, |
||||
sending=False, |
||||
) |
||||
pads.append(pad) |
||||
return pads |
||||
|
||||
|
||||
def decrypt_sum( |
||||
deckey: bytes, |
||||
enckey: bytes, |
||||
pubnonces: List[bytes], |
||||
context: bytes, |
||||
idx: int, |
||||
sum_ciphertexts: Scalar, |
||||
) -> Scalar: |
||||
if idx >= len(pubnonces): |
||||
raise IndexError |
||||
pads = decaps_multi(deckey, enckey, pubnonces, context, idx) |
||||
sum_plaintexts: Scalar = sum_ciphertexts - Scalar.sum(*pads) |
||||
return sum_plaintexts |
||||
|
||||
|
||||
### |
||||
### Messages |
||||
### |
||||
|
||||
|
||||
class ParticipantMsg(NamedTuple): |
||||
simpl_pmsg: simplpedpop.ParticipantMsg |
||||
pubnonce: bytes |
||||
enc_shares: List[Scalar] |
||||
|
||||
|
||||
class CoordinatorMsg(NamedTuple): |
||||
simpl_cmsg: simplpedpop.CoordinatorMsg |
||||
pubnonces: List[bytes] |
||||
|
||||
|
||||
class CoordinatorInvestigationMsg(NamedTuple): |
||||
enc_partial_secshares: List[Scalar] |
||||
partial_pubshares: List[GE] |
||||
|
||||
|
||||
### |
||||
### Participant |
||||
### |
||||
|
||||
|
||||
class ParticipantState(NamedTuple): |
||||
simpl_state: simplpedpop.ParticipantState |
||||
pubnonce: bytes |
||||
enckeys: List[bytes] |
||||
idx: int |
||||
|
||||
|
||||
class ParticipantInvestigationData(NamedTuple): |
||||
simpl_bstate: simplpedpop.ParticipantInvestigationData |
||||
enc_secshare: Scalar |
||||
pads: List[Scalar] |
||||
|
||||
|
||||
def serialize_enc_context(t: int, enckeys: List[bytes]) -> bytes: |
||||
return t.to_bytes(4, byteorder="big") + b"".join(enckeys) |
||||
|
||||
|
||||
def participant_step1( |
||||
seed: bytes, |
||||
deckey: bytes, |
||||
enckeys: List[bytes], |
||||
t: int, |
||||
idx: int, |
||||
random: bytes, |
||||
) -> Tuple[ParticipantState, ParticipantMsg]: |
||||
if t >= 2 ** (4 * 8): |
||||
raise ValueError |
||||
if len(random) != 32: |
||||
raise ValueError |
||||
n = len(enckeys) |
||||
|
||||
# Derive an encryption nonce and a seed for SimplPedPop. |
||||
# |
||||
# SimplPedPop will use its seed to derive the secret shares, which we will |
||||
# encrypt using the encryption nonce. That means that all entropy used in |
||||
# the derivation of simpl_seed should also be in the derivation of the |
||||
# pubnonce, to ensure that we never encrypt different secret shares with the |
||||
# same encryption pads. The foolproof way to achieve this is to simply |
||||
# derive the nonce from simpl_seed. |
||||
enc_context = serialize_enc_context(t, enckeys) |
||||
simpl_seed = tagged_hash_bip_dkg("encpedpop seed", seed + random + enc_context) |
||||
secnonce = tagged_hash_bip_dkg("encpedpop secnonce", simpl_seed) |
||||
pubnonce = pubkey_gen_plain(secnonce) |
||||
|
||||
simpl_state, simpl_pmsg, shares = simplpedpop.participant_step1( |
||||
simpl_seed, t, n, idx |
||||
) |
||||
assert len(shares) == n |
||||
|
||||
enc_shares = encrypt_multi( |
||||
secnonce, pubnonce, deckey, enckeys, enc_context, idx, shares |
||||
) |
||||
|
||||
pmsg = ParticipantMsg(simpl_pmsg, pubnonce, enc_shares) |
||||
state = ParticipantState(simpl_state, pubnonce, enckeys, idx) |
||||
return state, pmsg |
||||
|
||||
|
||||
def participant_step2( |
||||
state: ParticipantState, |
||||
deckey: bytes, |
||||
cmsg: CoordinatorMsg, |
||||
enc_secshare: Scalar, |
||||
) -> Tuple[simplpedpop.DKGOutput, bytes]: |
||||
simpl_state, pubnonce, enckeys, idx = state |
||||
simpl_cmsg, pubnonces = cmsg |
||||
|
||||
reported_pubnonce = pubnonces[idx] |
||||
if reported_pubnonce != pubnonce: |
||||
raise FaultyCoordinatorError("Coordinator replied with wrong pubnonce") |
||||
|
||||
enc_context = serialize_enc_context(simpl_state.t, enckeys) |
||||
pads = decaps_multi(deckey, enckeys[idx], pubnonces, enc_context, idx) |
||||
secshare = enc_secshare - Scalar.sum(*pads) |
||||
|
||||
try: |
||||
dkg_output, eq_input = simplpedpop.participant_step2( |
||||
simpl_state, simpl_cmsg, secshare |
||||
) |
||||
except UnknownFaultyParticipantOrCoordinatorError as e: |
||||
assert isinstance(e.inv_data, simplpedpop.ParticipantInvestigationData) |
||||
# Translate simplpedpop.ParticipantInvestigationData into our own |
||||
# encpedpop.ParticipantInvestigationData. |
||||
inv_data = ParticipantInvestigationData(e.inv_data, enc_secshare, pads) |
||||
raise UnknownFaultyParticipantOrCoordinatorError(inv_data, e.args) from e |
||||
|
||||
eq_input += b"".join(enckeys) + b"".join(pubnonces) |
||||
return dkg_output, eq_input |
||||
|
||||
|
||||
def participant_investigate( |
||||
error: UnknownFaultyParticipantOrCoordinatorError, |
||||
cinv: CoordinatorInvestigationMsg, |
||||
) -> NoReturn: |
||||
simpl_inv_data, enc_secshare, pads = error.inv_data |
||||
enc_partial_secshares, partial_pubshares = cinv |
||||
if len(enc_partial_secshares) != len(pads): |
||||
raise ValueError |
||||
partial_secshares = [ |
||||
enc_partial_secshare - pad |
||||
for enc_partial_secshare, pad in zip(enc_partial_secshares, pads) |
||||
] |
||||
|
||||
simpl_cinv = simplpedpop.CoordinatorInvestigationMsg(partial_pubshares) |
||||
try: |
||||
simplpedpop.participant_investigate( |
||||
UnknownFaultyParticipantOrCoordinatorError(simpl_inv_data), |
||||
simpl_cinv, |
||||
partial_secshares, |
||||
) |
||||
except simplpedpop.SecshareSumError as e: |
||||
# The secshare is not equal to the sum of the partial secshares in the |
||||
# investigation message. Since the encryption is additively homomorphic, |
||||
# this can only happen if the sum of the *encrypted* secshare is not |
||||
# equal to the sum of the encrypted partial sechares, which is the |
||||
# coordinator's fault. |
||||
assert Scalar.sum(*enc_partial_secshares) != enc_secshare |
||||
raise FaultyCoordinatorError( |
||||
"Sum of encrypted partial secshares not equal to encrypted secshare" |
||||
) from e |
||||
|
||||
|
||||
### |
||||
### Coordinator |
||||
### |
||||
|
||||
|
||||
def coordinator_step( |
||||
pmsgs: List[ParticipantMsg], |
||||
t: int, |
||||
enckeys: List[bytes], |
||||
) -> Tuple[CoordinatorMsg, simplpedpop.DKGOutput, bytes, List[Scalar]]: |
||||
n = len(enckeys) |
||||
if n != len(pmsgs): |
||||
raise ValueError |
||||
|
||||
simpl_pmsgs = [pmsg.simpl_pmsg for pmsg in pmsgs] |
||||
simpl_cmsg, dkg_output, eq_input = simplpedpop.coordinator_step(simpl_pmsgs, t, n) |
||||
pubnonces = [pmsg.pubnonce for pmsg in pmsgs] |
||||
for i in range(n): |
||||
if len(pmsgs[i].enc_shares) != n: |
||||
raise FaultyParticipantError( |
||||
i, "Participant sent enc_shares with invalid length" |
||||
) |
||||
enc_secshares = [ |
||||
Scalar.sum(*([pmsg.enc_shares[i] for pmsg in pmsgs])) for i in range(n) |
||||
] |
||||
eq_input += b"".join(enckeys) + b"".join(pubnonces) |
||||
|
||||
# In ChillDKG, the coordinator needs to broadcast the entire enc_secshares |
||||
# array to all participants. But in pure EncPedPop, the coordinator needs to |
||||
# send to each participant i only their entry enc_secshares[i]. |
||||
# |
||||
# Since broadcasting the entire array is not necessary, we don't include it |
||||
# in encpedpop.CoordinatorMsg, but only return it as a side output, so that |
||||
# chilldkg.coordinator_step can pick it up. Implementations of pure |
||||
# EncPedPop will need to decide how to transmit enc_secshares[i] to |
||||
# participant i for participant_step2(); we leave this unspecified. |
||||
return ( |
||||
CoordinatorMsg(simpl_cmsg, pubnonces), |
||||
dkg_output, |
||||
eq_input, |
||||
enc_secshares, |
||||
) |
||||
|
||||
|
||||
def coordinator_investigate( |
||||
pmsgs: List[ParticipantMsg], |
||||
) -> List[CoordinatorInvestigationMsg]: |
||||
n = len(pmsgs) |
||||
simpl_pmsgs = [pmsg.simpl_pmsg for pmsg in pmsgs] |
||||
|
||||
all_enc_partial_secshares = [ |
||||
[pmsg.enc_shares[i] for pmsg in pmsgs] for i in range(n) |
||||
] |
||||
simpl_cinvs = simplpedpop.coordinator_investigate(simpl_pmsgs) |
||||
cinvs = [ |
||||
CoordinatorInvestigationMsg( |
||||
all_enc_partial_secshares[i], simpl_cinvs[i].partial_pubshares |
||||
) |
||||
for i in range(n) |
||||
] |
||||
return cinvs |
||||
@ -0,0 +1,327 @@
|
||||
from secrets import token_bytes as random_bytes |
||||
from typing import List, NamedTuple, NewType, Tuple, Optional, NoReturn |
||||
|
||||
from ..secp256k1lab.bip340 import schnorr_sign, schnorr_verify |
||||
from ..secp256k1lab.secp256k1 import GE, Scalar |
||||
from .util import ( |
||||
BIP_TAG, |
||||
FaultyParticipantOrCoordinatorError, |
||||
FaultyCoordinatorError, |
||||
UnknownFaultyParticipantOrCoordinatorError, |
||||
) |
||||
from .vss import VSS, VSSCommitment |
||||
|
||||
|
||||
### |
||||
### Exceptions |
||||
### |
||||
|
||||
|
||||
class SecshareSumError(ValueError): |
||||
pass |
||||
|
||||
|
||||
### |
||||
### Proofs of possession (pops) |
||||
### |
||||
|
||||
|
||||
Pop = NewType("Pop", bytes) |
||||
|
||||
POP_MSG_TAG = BIP_TAG + "pop message" |
||||
|
||||
|
||||
def pop_msg(idx: int) -> bytes: |
||||
return idx.to_bytes(4, byteorder="big") |
||||
|
||||
|
||||
def pop_prove(seckey: bytes, idx: int) -> Pop: |
||||
sig = schnorr_sign( |
||||
pop_msg(idx), seckey, aux_rand=random_bytes(32), tag_prefix=POP_MSG_TAG |
||||
) |
||||
return Pop(sig) |
||||
|
||||
|
||||
def pop_verify(pop: Pop, pubkey: bytes, idx: int) -> bool: |
||||
return schnorr_verify(pop_msg(idx), pubkey, pop, tag_prefix=POP_MSG_TAG) |
||||
|
||||
|
||||
### |
||||
### Messages |
||||
### |
||||
|
||||
|
||||
class ParticipantMsg(NamedTuple): |
||||
com: VSSCommitment |
||||
pop: Pop |
||||
|
||||
|
||||
class CoordinatorMsg(NamedTuple): |
||||
coms_to_secrets: List[GE] |
||||
sum_coms_to_nonconst_terms: List[GE] |
||||
pops: List[Pop] |
||||
|
||||
def to_bytes(self) -> bytes: |
||||
return b"".join( |
||||
[ |
||||
P.to_bytes_compressed_with_infinity() |
||||
for P in self.coms_to_secrets + self.sum_coms_to_nonconst_terms |
||||
] |
||||
) + b"".join(self.pops) |
||||
|
||||
|
||||
class CoordinatorInvestigationMsg(NamedTuple): |
||||
partial_pubshares: List[GE] |
||||
|
||||
|
||||
### |
||||
### Other common definitions |
||||
### |
||||
|
||||
|
||||
class DKGOutput(NamedTuple): |
||||
secshare: Optional[bytes] # None for coordinator |
||||
threshold_pubkey: bytes |
||||
pubshares: List[bytes] |
||||
|
||||
|
||||
def assemble_sum_coms( |
||||
coms_to_secrets: List[GE], sum_coms_to_nonconst_terms: List[GE] |
||||
) -> VSSCommitment: |
||||
# Sum the commitments to the secrets |
||||
return VSSCommitment( |
||||
[GE.sum(*(c for c in coms_to_secrets))] + sum_coms_to_nonconst_terms |
||||
) |
||||
|
||||
|
||||
### |
||||
### Participant |
||||
### |
||||
|
||||
|
||||
class ParticipantState(NamedTuple): |
||||
t: int |
||||
n: int |
||||
idx: int |
||||
com_to_secret: GE |
||||
|
||||
|
||||
class ParticipantInvestigationData(NamedTuple): |
||||
n: int |
||||
idx: int |
||||
secshare: Scalar |
||||
pubshare: GE |
||||
|
||||
|
||||
# To keep the algorithms of SimplPedPop and EncPedPop purely non-interactive |
||||
# computations, we omit explicit invocations of an interactive equality check |
||||
# protocol. ChillDKG will take care of invoking the equality check protocol. |
||||
|
||||
|
||||
def participant_step1( |
||||
seed: bytes, t: int, n: int, idx: int |
||||
) -> Tuple[ |
||||
ParticipantState, |
||||
ParticipantMsg, |
||||
# The following return value is a list of n partial secret shares generated |
||||
# by this participant. The item at index i is supposed to be made available |
||||
# to participant i privately, e.g., via an external secure channel. See also |
||||
# the function participant_step2_prepare_secshare(). |
||||
List[Scalar], |
||||
]: |
||||
if t > n: |
||||
raise ValueError |
||||
if idx >= n: |
||||
raise IndexError |
||||
if len(seed) != 32: |
||||
raise ValueError |
||||
|
||||
vss = VSS.generate(seed, t) # OverflowError if t >= 2**32 |
||||
partial_secshares_from_me = vss.secshares(n) |
||||
pop = pop_prove(vss.secret().to_bytes(), idx) |
||||
|
||||
com = vss.commit() |
||||
com_to_secret = com.commitment_to_secret() |
||||
msg = ParticipantMsg(com, pop) |
||||
state = ParticipantState(t, n, idx, com_to_secret) |
||||
return state, msg, partial_secshares_from_me |
||||
|
||||
|
||||
# Helper function to prepare the secshare for participant idx's |
||||
# participant_step2() by summing the partial_secshares returned by all |
||||
# participants' participant_step1(). |
||||
# |
||||
# In a pure run of SimplPedPop where secret shares are sent via external secure |
||||
# channels (i.e., EncPedPop is not used), each participant needs to run this |
||||
# function in preparation of their participant_step2(). Since this computation |
||||
# involves secret data, it cannot be delegated to the coordinator as opposed to |
||||
# other aggregation steps. |
||||
# |
||||
# If EncPedPop is used instead (as a wrapper of SimplPedPop), the coordinator |
||||
# can securely aggregate the encrypted partial secshares into an encrypted |
||||
# secshare by exploiting the additively homomorphic property of the encryption. |
||||
def participant_step2_prepare_secshare( |
||||
partial_secshares: List[Scalar], |
||||
) -> Scalar: |
||||
secshare: Scalar # REVIEW Work around missing type annotation of Scalar.sum |
||||
secshare = Scalar.sum(*partial_secshares) |
||||
return secshare |
||||
|
||||
|
||||
def participant_step2( |
||||
state: ParticipantState, |
||||
cmsg: CoordinatorMsg, |
||||
secshare: Scalar, |
||||
) -> Tuple[DKGOutput, bytes]: |
||||
t, n, idx, com_to_secret = state |
||||
coms_to_secrets, sum_coms_to_nonconst_terms, pops = cmsg |
||||
|
||||
if ( |
||||
len(coms_to_secrets) != n |
||||
or len(sum_coms_to_nonconst_terms) != t - 1 |
||||
or len(pops) != n |
||||
): |
||||
raise ValueError |
||||
|
||||
if coms_to_secrets[idx] != com_to_secret: |
||||
raise FaultyCoordinatorError( |
||||
"Coordinator sent unexpected first group element for local index" |
||||
) |
||||
|
||||
for i in range(n): |
||||
if i == idx: |
||||
# No need to check our own pop. |
||||
continue |
||||
if coms_to_secrets[i].infinity: |
||||
raise FaultyParticipantOrCoordinatorError( |
||||
i, "Participant sent invalid commitment" |
||||
) |
||||
# This can be optimized: We serialize the coms_to_secrets[i] here, but |
||||
# schnorr_verify (inside pop_verify) will need to deserialize it again, which |
||||
# involves computing a square root to obtain the y coordinate. |
||||
if not pop_verify(pops[i], coms_to_secrets[i].to_bytes_xonly(), i): |
||||
raise FaultyParticipantOrCoordinatorError( |
||||
i, "Participant sent invalid proof-of-knowledge" |
||||
) |
||||
|
||||
sum_coms = assemble_sum_coms(coms_to_secrets, sum_coms_to_nonconst_terms) |
||||
# Verifying the tweaked secshare against the tweaked pubshare is equivalent |
||||
# to verifying the untweaked secshare against the untweaked pubshare, but |
||||
# avoids computing the untweaked pubshare in the happy path and thereby |
||||
# moves a group addition to the error path. |
||||
sum_coms_tweaked, tweak, pubtweak = sum_coms.invalid_taproot_commit() |
||||
pubshare_tweaked = sum_coms_tweaked.pubshare(idx) |
||||
secshare_tweaked = secshare + tweak |
||||
if not VSSCommitment.verify_secshare(secshare_tweaked, pubshare_tweaked): |
||||
pubshare = pubshare_tweaked - pubtweak |
||||
raise UnknownFaultyParticipantOrCoordinatorError( |
||||
ParticipantInvestigationData(n, idx, secshare, pubshare), |
||||
"Received invalid secshare, " |
||||
"consider investigation procedure to determine faulty party", |
||||
) |
||||
|
||||
threshold_pubkey = sum_coms_tweaked.commitment_to_secret() |
||||
pubshares = [ |
||||
sum_coms_tweaked.pubshare(i) |
||||
if i != idx |
||||
else pubshare_tweaked # We have computed our own pubshare already. |
||||
for i in range(n) |
||||
] |
||||
dkg_output = DKGOutput( |
||||
secshare_tweaked.to_bytes(), |
||||
threshold_pubkey.to_bytes_compressed(), |
||||
[pubshare.to_bytes_compressed() for pubshare in pubshares], |
||||
) |
||||
eq_input = t.to_bytes(4, byteorder="big") + sum_coms.to_bytes() |
||||
return dkg_output, eq_input |
||||
|
||||
|
||||
def participant_investigate( |
||||
error: UnknownFaultyParticipantOrCoordinatorError, |
||||
cinv: CoordinatorInvestigationMsg, |
||||
partial_secshares: List[Scalar], |
||||
) -> NoReturn: |
||||
n, idx, secshare, pubshare = error.inv_data |
||||
if len(partial_secshares) != n: |
||||
raise ValueError |
||||
|
||||
partial_pubshares = cinv.partial_pubshares |
||||
|
||||
if GE.sum(*partial_pubshares) != pubshare: |
||||
raise FaultyCoordinatorError("Sum of partial pubshares not equal to pubshare") |
||||
|
||||
if Scalar.sum(*partial_secshares) != secshare: |
||||
raise SecshareSumError("Sum of partial secshares not equal to secshare") |
||||
|
||||
for i in range(n): |
||||
if not VSSCommitment.verify_secshare( |
||||
partial_secshares[i], partial_pubshares[i] |
||||
): |
||||
if i != idx: |
||||
raise FaultyParticipantOrCoordinatorError( |
||||
i, "Participant sent invalid partial secshare" |
||||
) |
||||
else: |
||||
# We are not faulty, so the coordinator must be. |
||||
raise FaultyCoordinatorError( |
||||
"Coordinator fiddled with the share from me to myself" |
||||
) |
||||
|
||||
# We now know: |
||||
# - The sum of the partial secshares is equal to the secshare. |
||||
# - The sum of the partial pubshares is equal to the pubshare. |
||||
# - Every partial secshare matches its corresponding partial pubshare. |
||||
# Hence, the secshare matches the pubshare. |
||||
assert VSSCommitment.verify_secshare(secshare, pubshare) |
||||
|
||||
# This should never happen (unless the caller fiddled with the inputs). |
||||
raise RuntimeError( |
||||
"participant_investigate() was called, but all inputs are consistent." |
||||
) |
||||
|
||||
|
||||
### |
||||
### Coordinator |
||||
### |
||||
|
||||
|
||||
def coordinator_step( |
||||
pmsgs: List[ParticipantMsg], t: int, n: int |
||||
) -> Tuple[CoordinatorMsg, DKGOutput, bytes]: |
||||
if len(pmsgs) != n: |
||||
raise ValueError |
||||
# Sum the commitments to the i-th coefficients for i > 0 |
||||
# |
||||
# This procedure corresponds to the one described by Pedersen in Section 5.1 |
||||
# of "Non-Interactive and Information-Theoretic Secure Verifiable Secret |
||||
# Sharing". However, we don't sum the commitments to the secrets (i == 0) |
||||
# because they'll be necessary to check the pops. |
||||
coms_to_secrets = [pmsg.com.commitment_to_secret() for pmsg in pmsgs] |
||||
# But we can sum the commitments to the non-constant terms. |
||||
sum_coms_to_nonconst_terms = [ |
||||
GE.sum(*(pmsg.com.commitment_to_nonconst_terms()[j] for pmsg in pmsgs)) |
||||
for j in range(t - 1) |
||||
] |
||||
pops = [pmsg.pop for pmsg in pmsgs] |
||||
cmsg = CoordinatorMsg(coms_to_secrets, sum_coms_to_nonconst_terms, pops) |
||||
|
||||
sum_coms = assemble_sum_coms(coms_to_secrets, sum_coms_to_nonconst_terms) |
||||
sum_coms_tweaked, _, _ = sum_coms.invalid_taproot_commit() |
||||
threshold_pubkey = sum_coms_tweaked.commitment_to_secret() |
||||
pubshares = [sum_coms_tweaked.pubshare(i) for i in range(n)] |
||||
|
||||
dkg_output = DKGOutput( |
||||
None, |
||||
threshold_pubkey.to_bytes_compressed(), |
||||
[pubshare.to_bytes_compressed() for pubshare in pubshares], |
||||
) |
||||
eq_input = t.to_bytes(4, byteorder="big") + sum_coms.to_bytes() |
||||
return cmsg, dkg_output, eq_input |
||||
|
||||
|
||||
def coordinator_investigate( |
||||
pmsgs: List[ParticipantMsg], |
||||
) -> List[CoordinatorInvestigationMsg]: |
||||
n = len(pmsgs) |
||||
all_partial_pubshares = [[pmsg.com.pubshare(i) for pmsg in pmsgs] for i in range(n)] |
||||
return [CoordinatorInvestigationMsg(all_partial_pubshares[i]) for i in range(n)] |
||||
@ -0,0 +1,103 @@
|
||||
from typing import Any |
||||
|
||||
from ..secp256k1lab.util import tagged_hash |
||||
|
||||
|
||||
BIP_TAG = "BIP DKG/" |
||||
|
||||
|
||||
def tagged_hash_bip_dkg(tag: str, msg: bytes) -> bytes: |
||||
return tagged_hash(BIP_TAG + tag, msg) |
||||
|
||||
|
||||
class ProtocolError(Exception): |
||||
"""Base exception for errors caused by received protocol messages.""" |
||||
|
||||
|
||||
class FaultyParticipantError(ProtocolError): |
||||
"""Raised if a participant is faulty. |
||||
|
||||
This exception is raised by the coordinator code when it detects faulty |
||||
behavior by a participant, i.e., a participant has deviated from the |
||||
protocol. The index of the participant is provided as part of the exception. |
||||
Assuming protocol messages have been transmitted correctly and the |
||||
coordinator itself is not faulty, this exception implies that the |
||||
participant is indeed faulty. |
||||
|
||||
This exception is raised only by the coordinator code. Some faulty behavior |
||||
by participants will be detected by the other participants instead. |
||||
See `FaultyParticipantOrCoordinatorError` for details. |
||||
|
||||
Attributes: |
||||
participant (int): Index of the faulty participant. |
||||
""" |
||||
|
||||
def __init__(self, participant: int, *args: Any): |
||||
self.participant = participant |
||||
super().__init__(participant, *args) |
||||
|
||||
|
||||
class FaultyParticipantOrCoordinatorError(ProtocolError): |
||||
"""Raised if another known participant or the coordinator is faulty. |
||||
|
||||
This exception is raised by the participant code when it detects what looks |
||||
like faulty behavior by a suspected participant. The index of the suspected |
||||
participant is provided as part of the exception. |
||||
|
||||
Importantly, this exception is not proof that the suspected participant is |
||||
indeed faulty. It is instead possible that the coordinator has deviated from |
||||
the protocol in a way that makes it look as if the suspected participant has |
||||
deviated from the protocol. In other words, assuming messages have been |
||||
transmitted correctly and the raising participant is not faulty, this |
||||
exception implies that |
||||
- the suspected participant is faulty, |
||||
- *or* the coordinator is faulty (and has framed the suspected |
||||
participant). |
||||
|
||||
This exception is raised only by the participant code. Some faulty behavior |
||||
by participants will be detected by the coordinator instead. See |
||||
`FaultyParticipantError` for details. |
||||
|
||||
Attributes: |
||||
participant (int): Index of the suspected participant. |
||||
""" |
||||
|
||||
def __init__(self, participant: int, *args: Any): |
||||
self.participant = participant |
||||
super().__init__(participant, *args) |
||||
|
||||
|
||||
class FaultyCoordinatorError(ProtocolError): |
||||
"""Raised if the coordinator is faulty. |
||||
|
||||
This exception is raised by the participant code when it detects faulty |
||||
behavior by the coordinator, i.e., the coordinator has deviated from the |
||||
protocol. Assuming protocol messages have been transmitted correctly and the |
||||
raising participant is not faulty, this exception implies that the |
||||
coordinator is indeed faulty. |
||||
""" |
||||
|
||||
|
||||
class UnknownFaultyParticipantOrCoordinatorError(ProtocolError): |
||||
"""Raised if another unknown participant or the coordinator is faulty. |
||||
|
||||
This exception is raised by the participant code when it detects what looks |
||||
like faulty behavior by some other participant, but there is insufficient |
||||
information to determine which participant should be suspected. |
||||
|
||||
To determine a suspected participant, the raising participant may choose to |
||||
run the optional investigation procedure of the protocol, which requires |
||||
obtaining an investigation message from the coordinator. See the |
||||
`participant_investigate` function for details. |
||||
|
||||
This is only raised for specific faulty behavior by another participant |
||||
which cannot be attributed to another participant without further help of |
||||
the coordinator (namely, sending invalid encrypted secret shares). |
||||
|
||||
Attributes: |
||||
inv_data: Information required to perform the investigation. |
||||
""" |
||||
|
||||
def __init__(self, inv_data: Any, *args: Any): |
||||
self.inv_data = inv_data |
||||
super().__init__(*args) |
||||
@ -0,0 +1,146 @@
|
||||
from __future__ import annotations |
||||
|
||||
from typing import List, Tuple |
||||
|
||||
from ..secp256k1lab.secp256k1 import GE, G, Scalar |
||||
from ..secp256k1lab.util import tagged_hash |
||||
|
||||
from .util import tagged_hash_bip_dkg |
||||
|
||||
|
||||
class Polynomial: |
||||
# A scalar polynomial. |
||||
# |
||||
# A polynomial f of degree at most t - 1 is represented by a list `coeffs` |
||||
# of t coefficients, i.e., f(x) = coeffs[0] + ... + coeffs[t-1] * |
||||
# x^(t-1).""" |
||||
coeffs: List[Scalar] |
||||
|
||||
def __init__(self, coeffs: List[Scalar]) -> None: |
||||
self.coeffs = coeffs |
||||
|
||||
def eval(self, x: Scalar) -> Scalar: |
||||
# Evaluate a polynomial at position x. |
||||
|
||||
value = Scalar(0) |
||||
# Reverse coefficients to compute evaluation via Horner's method |
||||
for coeff in self.coeffs[::-1]: |
||||
value = value * x + coeff |
||||
return value |
||||
|
||||
def __call__(self, x: Scalar) -> Scalar: |
||||
return self.eval(x) |
||||
|
||||
|
||||
class VSSCommitment: |
||||
ges: List[GE] |
||||
|
||||
def __init__(self, ges: List[GE]) -> None: |
||||
self.ges = ges |
||||
|
||||
def t(self) -> int: |
||||
return len(self.ges) |
||||
|
||||
def pubshare(self, i: int) -> GE: |
||||
pubshare: GE = GE.batch_mul( |
||||
*(((i + 1) ** j, self.ges[j]) for j in range(0, len(self.ges))) |
||||
) |
||||
return pubshare |
||||
|
||||
@staticmethod |
||||
def verify_secshare(secshare: Scalar, pubshare: GE) -> bool: |
||||
# The caller needs to provide the correct pubshare(i) |
||||
actual = secshare * G |
||||
valid: bool = actual == pubshare |
||||
return valid |
||||
|
||||
def to_bytes(self) -> bytes: |
||||
# Return commitments to the coefficients of f. |
||||
return b"".join([ge.to_bytes_compressed_with_infinity() for ge in self.ges]) |
||||
|
||||
def __add__(self, other: VSSCommitment) -> VSSCommitment: |
||||
assert self.t() == other.t() |
||||
return VSSCommitment([self.ges[i] + other.ges[i] for i in range(self.t())]) |
||||
|
||||
@staticmethod |
||||
def from_bytes_and_t(b: bytes, t: int) -> VSSCommitment: |
||||
if len(b) != 33 * t: |
||||
raise ValueError |
||||
ges = [GE.from_bytes_compressed(b[i : i + 33]) for i in range(0, 33 * t, 33)] |
||||
return VSSCommitment(ges) |
||||
|
||||
def commitment_to_secret(self) -> GE: |
||||
return self.ges[0] |
||||
|
||||
def commitment_to_nonconst_terms(self) -> List[GE]: |
||||
return self.ges[1 : self.t()] |
||||
|
||||
def invalid_taproot_commit(self) -> Tuple[VSSCommitment, Scalar, GE]: |
||||
# Return a modified VSS commitment such that the threshold public key |
||||
# generated from it has an unspendable BIP 341 Taproot script path. |
||||
# |
||||
# Specifically, for a VSS commitment `com`, we have: |
||||
# `com.invalid_taproot_commit().commitment_to_secret() = com.commitment_to_secret() + t*G`. |
||||
# |
||||
# The tweak `t` commits to an empty message, which is invalid according |
||||
# to BIP 341 for Taproot script spends. This follows BIP 341's |
||||
# recommended approach for committing to an unspendable script path. |
||||
# |
||||
# This prevents a malicious participant from secretly inserting a *valid* |
||||
# Taproot commitment to a script path into the summed VSS commitment during |
||||
# the DKG protocol. If the resulting threshold public key was used directly |
||||
# in a BIP 341 Taproot output, the malicious participant would be able to |
||||
# spend the output using their hidden script path. |
||||
# |
||||
# The function returns the updated VSS commitment and the tweak `t` which |
||||
# must be added to all secret shares of the commitment. |
||||
pk = self.commitment_to_secret() |
||||
secshare_tweak = Scalar.from_bytes_checked( |
||||
tagged_hash("TapTweak", pk.to_bytes_compressed()) |
||||
) |
||||
pubshare_tweak = secshare_tweak * G |
||||
vss_tweak = VSSCommitment([pubshare_tweak] + [GE()] * (self.t() - 1)) |
||||
return (self + vss_tweak, secshare_tweak, pubshare_tweak) |
||||
|
||||
|
||||
class VSS: |
||||
f: Polynomial |
||||
|
||||
def __init__(self, f: Polynomial) -> None: |
||||
self.f = f |
||||
|
||||
@staticmethod |
||||
def generate(seed: bytes, t: int) -> VSS: |
||||
coeffs = [ |
||||
Scalar.from_bytes_checked( |
||||
tagged_hash_bip_dkg("vss coeffs", seed + i.to_bytes(4, byteorder="big")) |
||||
) |
||||
for i in range(t) |
||||
] |
||||
return VSS(Polynomial(coeffs)) |
||||
|
||||
def secshare_for(self, i: int) -> Scalar: |
||||
# Return the secret share for the participant with index i. |
||||
# |
||||
# This computes f(i+1). |
||||
if i < 0: |
||||
raise ValueError(f"Invalid participant index: {i}") |
||||
x = Scalar(i + 1) |
||||
# Ensure we don't compute f(0), which is the secret. |
||||
assert x != Scalar(0) |
||||
return self.f(x) |
||||
|
||||
def secshares(self, n: int) -> List[Scalar]: |
||||
# Return the secret shares for the participants with indices 0..n-1. |
||||
# |
||||
# This computes [f(1), ..., f(n)]. |
||||
return [self.secshare_for(i) for i in range(0, n)] |
||||
|
||||
def commit(self) -> VSSCommitment: |
||||
return VSSCommitment([c * G for c in self.f.coeffs]) |
||||
|
||||
def secret(self) -> Scalar: |
||||
# Return the secret to be shared. |
||||
# |
||||
# This computes f(0). |
||||
return self.f.coeffs[0] |
||||
@ -0,0 +1,442 @@
|
||||
# -*- coding: utf-8 -*- |
||||
|
||||
# BIP FROST Signing reference implementation |
||||
# |
||||
# It's worth noting that many functions, types, and exceptions were directly |
||||
# copied or modified from the MuSig2 (BIP 327) reference code, found at: |
||||
# https://github.com/bitcoin/bips/blob/master/bip-0327/reference.py |
||||
# |
||||
# WARNING: This implementation is for demonstration purposes only and _not_ to |
||||
# be used in production environments. The code is vulnerable to timing attacks, |
||||
# for example. |
||||
|
||||
from typing import List, Optional, Tuple, NewType, NamedTuple, Sequence |
||||
import itertools |
||||
import secrets |
||||
|
||||
from ..secp256k1lab.keys import pubkey_gen_plain |
||||
from ..secp256k1lab.secp256k1 import G, GE, Scalar |
||||
from ..secp256k1lab.util import int_from_bytes, tagged_hash |
||||
|
||||
PlainPk = NewType('PlainPk', bytes) |
||||
XonlyPk = NewType('XonlyPk', bytes) |
||||
|
||||
# There are two types of exceptions that can be raised by this implementation: |
||||
# - ValueError for indicating that an input doesn't conform to some function |
||||
# precondition (e.g. an input array is the wrong length, a serialized |
||||
# representation doesn't have the correct format). |
||||
# - InvalidContributionError for indicating that a signer (or the |
||||
# aggregator) is misbehaving in the protocol. |
||||
# |
||||
# Assertions are used to (1) satisfy the type-checking system, and (2) check for |
||||
# inconvenient events that can't happen except with negligible probability (e.g. |
||||
# output of a hash function is 0) and can't be manually triggered by any |
||||
# signer. |
||||
|
||||
# This exception is raised if a party (signer or nonce aggregator) sends invalid |
||||
# values. Actual implementations should not crash when receiving invalid |
||||
# contributions. Instead, they should hold the offending party accountable. |
||||
class InvalidContributionError(Exception): |
||||
def __init__(self, signer_id, contrib): |
||||
# participant identifier of the signer who sent the invalid value |
||||
self.id = signer_id |
||||
# contrib is one of "pubkey", "pubnonce", "aggnonce", or "psig". |
||||
self.contrib = contrib |
||||
|
||||
def xbytes(P: GE) -> bytes: |
||||
return P.to_bytes_xonly() |
||||
|
||||
def cbytes(P: GE) -> bytes: |
||||
return P.to_bytes_compressed() |
||||
|
||||
def cbytes_ext(P: GE) -> bytes: |
||||
if P.infinity: |
||||
return (0).to_bytes(33, byteorder='big') |
||||
return cbytes(P) |
||||
|
||||
def cpoint(x: bytes) -> GE: |
||||
return GE.from_bytes_compressed(x) |
||||
|
||||
def cpoint_ext(x: bytes) -> GE: |
||||
if x == (0).to_bytes(33, 'big'): |
||||
return GE() |
||||
else: |
||||
return cpoint(x) |
||||
|
||||
# Return the plain public key corresponding to a given secret key |
||||
def individual_pk(seckey: bytes) -> PlainPk: |
||||
return PlainPk(pubkey_gen_plain(seckey)) |
||||
|
||||
# TODO: add my_id < max_participants check |
||||
def derive_interpolating_value(ids: List[int], my_id: int) -> Scalar: |
||||
if not my_id in ids: |
||||
raise ValueError('The signer\'s id must be present in the participant identifier list.') |
||||
if not all(ids.count(my_id) <= 1 for my_id in ids): |
||||
raise ValueError('The participant identifier list must contain unique elements.') |
||||
#todo: turn this into raise ValueError? |
||||
assert 0 <= my_id < 2**32 |
||||
num, deno = 1, 1 |
||||
for curr_id in ids: |
||||
if curr_id == my_id: |
||||
continue |
||||
num *= curr_id + 1 |
||||
deno *= (curr_id - my_id) |
||||
return Scalar.from_int_wrapping(num * pow(deno, GE.ORDER - 2, GE.ORDER)) |
||||
|
||||
def check_pubshares_correctness(secshares: List[bytes], pubshares: List[PlainPk]) -> bool: |
||||
assert len(secshares) == len(pubshares) |
||||
for secshare, pubshare in zip(secshares, pubshares): |
||||
if not individual_pk(secshare) == pubshare: |
||||
return False |
||||
return True |
||||
|
||||
def check_group_pubkey_correctness(min_participants: int, group_pk: PlainPk, ids: List[int], pubshares: List[PlainPk]) -> bool: |
||||
assert len(ids) == len(pubshares) |
||||
assert len(ids) >= min_participants |
||||
|
||||
max_participants = len(ids) |
||||
# loop through all possible number of signers |
||||
for signer_count in range(min_participants, max_participants + 1): |
||||
# loop through all possible signer sets with length `signer_count` |
||||
for signer_set in itertools.combinations(zip(ids, pubshares), signer_count): |
||||
signer_ids = [pid for pid, pubshare in signer_set] |
||||
signer_pubshares = [pubshare for pid, pubshare in signer_set] |
||||
expected_pk = derive_group_pubkey(signer_pubshares, signer_ids) |
||||
if expected_pk != group_pk: |
||||
return False |
||||
return True |
||||
|
||||
def check_frost_key_compatibility(max_participants: int, min_participants: int, group_pk: PlainPk, ids: List[int], secshares: List[bytes], pubshares: List[PlainPk]) -> bool: |
||||
if not max_participants >= min_participants > 1: |
||||
return False |
||||
if not len(ids) == len(secshares) == len(pubshares) == max_participants: |
||||
return False |
||||
pubshare_check = check_pubshares_correctness(secshares, pubshares) |
||||
group_pk_check = check_group_pubkey_correctness(min_participants, group_pk, ids, pubshares) |
||||
return pubshare_check and group_pk_check |
||||
|
||||
TweakContext = NamedTuple('TweakContext', [('Q', GE), |
||||
('gacc', int), |
||||
('tacc', int)]) |
||||
AGGREGATOR_ID = None |
||||
|
||||
def get_xonly_pk(tweak_ctx: TweakContext) -> XonlyPk: |
||||
Q, _, _ = tweak_ctx |
||||
return XonlyPk(xbytes(Q)) |
||||
|
||||
def get_plain_pk(tweak_ctx: TweakContext) -> PlainPk: |
||||
Q, _, _ = tweak_ctx |
||||
return PlainPk(cbytes(Q)) |
||||
|
||||
#nit: switch the args ordering |
||||
def derive_group_pubkey(pubshares: List[PlainPk], ids: List[int]) -> PlainPk: |
||||
assert len(pubshares) == len(ids) |
||||
# assert AGGREGATOR_ID not in ids |
||||
Q = GE() |
||||
for my_id, pubshare in zip(ids, pubshares): |
||||
try: |
||||
X_i = cpoint(pubshare) |
||||
except ValueError: |
||||
raise InvalidContributionError(my_id, "pubshare") |
||||
lam_i = derive_interpolating_value(ids, my_id) |
||||
Q = Q + lam_i * X_i |
||||
# Q is not the point at infinity except with negligible probability. |
||||
assert not Q.infinity |
||||
return PlainPk(cbytes(Q)) |
||||
|
||||
def tweak_ctx_init(pubshares: List[PlainPk], ids: List[int]) -> TweakContext: |
||||
group_pk = derive_group_pubkey(pubshares, ids) |
||||
Q = cpoint(group_pk) |
||||
gacc = 1 |
||||
tacc = 0 |
||||
return TweakContext(Q, gacc, tacc) |
||||
|
||||
def apply_tweak(tweak_ctx: TweakContext, tweak: bytes, is_xonly: bool) -> TweakContext: |
||||
if len(tweak) != 32: |
||||
raise ValueError('The tweak must be a 32-byte array.') |
||||
Q, gacc, tacc = tweak_ctx |
||||
if is_xonly and not Q.has_even_y(): |
||||
g = Scalar(-1) |
||||
else: |
||||
g = Scalar(1) |
||||
try: |
||||
t = Scalar.from_bytes_checked(tweak) |
||||
except ValueError: |
||||
raise ValueError('The tweak must be less than n.') |
||||
Q_ = g * Q + t * G |
||||
if Q_.infinity: |
||||
raise ValueError('The result of tweaking cannot be infinity.') |
||||
gacc_ = g * gacc |
||||
tacc_ = t + g * tacc |
||||
return TweakContext(Q_, gacc_, tacc_) |
||||
|
||||
def bytes_xor(a: bytes, b: bytes) -> bytes: |
||||
return bytes(x ^ y for x, y in zip(a, b)) |
||||
|
||||
def nonce_hash(rand: bytes, pubshare: PlainPk, group_pk: XonlyPk, i: int, msg_prefixed: bytes, extra_in: bytes) -> int: |
||||
buf = b'' |
||||
buf += rand |
||||
buf += len(pubshare).to_bytes(1, 'big') |
||||
buf += pubshare |
||||
buf += len(group_pk).to_bytes(1, 'big') |
||||
buf += group_pk |
||||
buf += msg_prefixed |
||||
buf += len(extra_in).to_bytes(4, 'big') |
||||
buf += extra_in |
||||
buf += i.to_bytes(1, 'big') |
||||
return int_from_bytes(tagged_hash('FROST/nonce', buf)) |
||||
|
||||
def nonce_gen_internal(rand_: bytes, secshare: Optional[bytes], pubshare: Optional[PlainPk], group_pk: Optional[XonlyPk], msg: Optional[bytes], extra_in: Optional[bytes]) -> Tuple[bytearray, bytes]: |
||||
if secshare is not None: |
||||
rand = bytes_xor(secshare, tagged_hash('FROST/aux', rand_)) |
||||
else: |
||||
rand = rand_ |
||||
if pubshare is None: |
||||
pubshare = PlainPk(b'') |
||||
if group_pk is None: |
||||
group_pk = XonlyPk(b'') |
||||
if msg is None: |
||||
msg_prefixed = b'\x00' |
||||
else: |
||||
msg_prefixed = b'\x01' |
||||
msg_prefixed += len(msg).to_bytes(8, 'big') |
||||
msg_prefixed += msg |
||||
if extra_in is None: |
||||
extra_in = b'' |
||||
k_1 = Scalar.from_int_wrapping(nonce_hash(rand, pubshare, group_pk, 0, msg_prefixed, extra_in)) |
||||
k_2 = Scalar.from_int_wrapping(nonce_hash(rand, pubshare, group_pk, 1, msg_prefixed, extra_in)) |
||||
# k_1 == 0 or k_2 == 0 cannot occur except with negligible probability. |
||||
assert k_1 != 0 |
||||
assert k_2 != 0 |
||||
R_s1 = k_1 * G |
||||
R_s2 = k_2 * G |
||||
assert not R_s1.infinity |
||||
assert not R_s2.infinity |
||||
pubnonce = cbytes(R_s1) + cbytes(R_s2) |
||||
# use mutable `bytearray` since secnonce need to be replaced with zeros during signing. |
||||
secnonce = bytearray(k_1.to_bytes() + k_2.to_bytes()) |
||||
return secnonce, pubnonce |
||||
|
||||
#think: can msg & extra_in be of any length here? |
||||
#think: why doesn't musig2 ref code check for `pk` length here? |
||||
#REVIEW: Why should group_pk be XOnlyPk here? Shouldn't it be PlainPk? |
||||
def nonce_gen(secshare: Optional[bytes], pubshare: Optional[PlainPk], group_pk: Optional[XonlyPk], msg: Optional[bytes], extra_in: Optional[bytes]) -> Tuple[bytearray, bytes]: |
||||
if secshare is not None and len(secshare) != 32: |
||||
raise ValueError('The optional byte array secshare must have length 32.') |
||||
if pubshare is not None and len(pubshare) != 33: |
||||
raise ValueError('The optional byte array pubshare must have length 33.') |
||||
if group_pk is not None and len(group_pk) != 32: |
||||
raise ValueError('The optional byte array group_pk must have length 32.') |
||||
# bench: will adding individual_pk(secshare) == pubshare check, increase the execution time significantly? |
||||
rand_ = secrets.token_bytes(32) |
||||
return nonce_gen_internal(rand_, secshare, pubshare, group_pk, msg, extra_in) |
||||
|
||||
# REVIEW should we raise value errors for: |
||||
# (1) duplicate ids |
||||
# (2) 0 <= id < max_participants < 2^32 |
||||
# in each function that takes `ids` as argument? |
||||
|
||||
# `ids` is typed as Sequence[Optional[int]] so that callers can pass either |
||||
# List[int] or List[Optional[int]] without triggering mypy invariance errors. |
||||
# Sequence is read-only and covariant. |
||||
def nonce_agg(pubnonces: List[bytes], ids: Sequence[Optional[int]]) -> bytes: |
||||
if len(pubnonces) != len(ids): |
||||
raise ValueError('The pubnonces and ids arrays must have the same length.') |
||||
aggnonce = b'' |
||||
for j in (1, 2): |
||||
R_j = GE() |
||||
for my_id, pubnonce in zip(ids, pubnonces): |
||||
try: |
||||
R_ij = cpoint(pubnonce[(j-1)*33:j*33]) |
||||
except ValueError: |
||||
raise InvalidContributionError(my_id, "pubnonce") |
||||
R_j = R_j + R_ij |
||||
aggnonce += cbytes_ext(R_j) |
||||
return aggnonce |
||||
|
||||
SessionContext = NamedTuple('SessionContext', [('aggnonce', bytes), |
||||
('identifiers', List[int]), |
||||
('pubshares', List[PlainPk]), |
||||
('tweaks', List[bytes]), |
||||
('is_xonly', List[bool]), |
||||
('msg', bytes)]) |
||||
|
||||
def group_pubkey_and_tweak(pubshares: List[PlainPk], ids: List[int], tweaks: List[bytes], is_xonly: List[bool]) -> TweakContext: |
||||
if len(pubshares) != len(ids): |
||||
raise ValueError('The pubshares and ids arrays must have the same length.') |
||||
if len(tweaks) != len(is_xonly): |
||||
raise ValueError('The tweaks and is_xonly arrays must have the same length.') |
||||
tweak_ctx = tweak_ctx_init(pubshares, ids) |
||||
v = len(tweaks) |
||||
for i in range(v): |
||||
tweak_ctx = apply_tweak(tweak_ctx, tweaks[i], is_xonly[i]) |
||||
return tweak_ctx |
||||
|
||||
def get_session_values(session_ctx: SessionContext) -> Tuple[GE, int, int, Scalar, GE, Scalar]: |
||||
(aggnonce, ids, pubshares, tweaks, is_xonly, msg) = session_ctx |
||||
Q, gacc, tacc = group_pubkey_and_tweak(pubshares, ids, tweaks, is_xonly) |
||||
# sort the ids before serializing because ROAST paper considers them as a set |
||||
ser_ids = serialize_ids(ids) |
||||
b = Scalar.from_bytes_wrapping(tagged_hash('FROST/noncecoef', ser_ids + aggnonce + xbytes(Q) + msg)) |
||||
try: |
||||
R_1 = cpoint_ext(aggnonce[0:33]) |
||||
R_2 = cpoint_ext(aggnonce[33:66]) |
||||
except ValueError: |
||||
# Nonce aggregator sent invalid nonces |
||||
raise InvalidContributionError(None, "aggnonce") |
||||
R_ = R_1 + b * R_2 |
||||
R = R_ if not R_.infinity else G |
||||
assert not R.infinity |
||||
e = Scalar.from_bytes_wrapping(tagged_hash('BIP0340/challenge', xbytes(R) + xbytes(Q) + msg)) |
||||
return (Q, gacc, tacc, b, R, e) |
||||
|
||||
def serialize_ids(ids: List[int]) -> bytes: |
||||
# REVIEW assert for ids not being unsigned values? |
||||
sorted_ids = sorted(ids) |
||||
ser_ids = b''.join( |
||||
i.to_bytes(4, byteorder="big", signed=False) for i in sorted_ids |
||||
) |
||||
return ser_ids |
||||
|
||||
def get_session_interpolating_value(session_ctx: SessionContext, my_id: int) -> Scalar: |
||||
(_, ids, _, _, _, _) = session_ctx |
||||
return derive_interpolating_value(ids, my_id) |
||||
|
||||
def session_has_signer_pubshare(session_ctx: SessionContext, pubshare: bytes) -> bool: |
||||
(_, _, pubshares_list, _, _, _) = session_ctx |
||||
return pubshare in pubshares_list |
||||
|
||||
def sign(secnonce: bytearray, secshare: bytes, my_id: int, session_ctx: SessionContext) -> bytes: |
||||
# do we really need the below check? |
||||
# add test vector for this check if confirmed |
||||
if not 0 <= my_id < 2**32: |
||||
raise ValueError('The signer\'s participant identifier is out of range') |
||||
(Q, gacc, _, b, R, e) = get_session_values(session_ctx) |
||||
# TODO: use `Scalar.from_bytes_nonzero_checked` for deserializing k1 and k2, once available |
||||
# in a secp256k1lab release (see https://github.com/secp256k1lab/secp256k1lab/pull/8) |
||||
try: |
||||
k_1_ = Scalar.from_bytes_checked(secnonce[0:32]) |
||||
if k_1_ == 0: # treat zero exactly like any other bad input |
||||
raise ValueError |
||||
except ValueError: |
||||
raise ValueError('first secnonce value is out of range.') |
||||
try: |
||||
k_2_ = Scalar.from_bytes_checked(secnonce[32:64]) |
||||
if k_2_ == 0: # treat zero exactly like any other bad input |
||||
raise ValueError |
||||
except ValueError: |
||||
raise ValueError('second secnonce value is out of range.') |
||||
# Overwrite the secnonce argument with zeros such that subsequent calls of |
||||
# sign with the same secnonce raise a ValueError. |
||||
secnonce[:] = bytearray(b'\x00'*64) |
||||
k_1 = k_1_ if R.has_even_y() else -k_1_ |
||||
k_2 = k_2_ if R.has_even_y() else -k_2_ |
||||
d_ = int_from_bytes(secshare) |
||||
if not 0 < d_ < GE.ORDER: |
||||
raise ValueError('The signer\'s secret share value is out of range.') |
||||
P = d_ * G |
||||
assert not P.infinity |
||||
pubshare = cbytes(P) |
||||
if not session_has_signer_pubshare(session_ctx, pubshare): |
||||
raise ValueError('The signer\'s pubshare must be included in the list of pubshares.') |
||||
a = get_session_interpolating_value(session_ctx, my_id) |
||||
g = Scalar(1) if Q.has_even_y() else Scalar(-1) |
||||
d = g * gacc * d_ |
||||
s = k_1 + b * k_2 + e * a * d |
||||
psig = s.to_bytes() |
||||
R_s1 = k_1_ * G |
||||
R_s2 = k_2_ * G |
||||
assert not R_s1.infinity |
||||
assert not R_s2.infinity |
||||
pubnonce = cbytes(R_s1) + cbytes(R_s2) |
||||
# Optional correctness check. The result of signing should pass signature verification. |
||||
assert partial_sig_verify_internal(psig, my_id, pubnonce, pubshare, session_ctx) |
||||
return psig |
||||
|
||||
# REVIEW should we hash the signer set (or pubshares) too? Otherwise same nonce will be generate even if the signer set changes |
||||
def det_nonce_hash(secshare_: bytes, aggothernonce: bytes, tweaked_gpk: bytes, msg: bytes, i: int) -> int: |
||||
buf = b'' |
||||
buf += secshare_ |
||||
buf += aggothernonce |
||||
buf += tweaked_gpk |
||||
buf += len(msg).to_bytes(8, 'big') |
||||
buf += msg |
||||
buf += i.to_bytes(1, 'big') |
||||
return int_from_bytes(tagged_hash('FROST/deterministic/nonce', buf)) |
||||
|
||||
def deterministic_sign(secshare: bytes, my_id: int, aggothernonce: bytes, ids: List[int], pubshares: List[PlainPk], tweaks: List[bytes], is_xonly: List[bool], msg: bytes, rand: Optional[bytes]) -> Tuple[bytes, bytes]: |
||||
if rand is not None: |
||||
secshare_ = bytes_xor(secshare, tagged_hash('FROST/aux', rand)) |
||||
else: |
||||
secshare_ = secshare |
||||
|
||||
tweaked_gpk = get_xonly_pk(group_pubkey_and_tweak(pubshares, ids, tweaks, is_xonly)) |
||||
|
||||
k_1 = Scalar.from_int_wrapping(det_nonce_hash(secshare_, aggothernonce, tweaked_gpk, msg, 0)) |
||||
k_2 = Scalar.from_int_wrapping(det_nonce_hash(secshare_, aggothernonce, tweaked_gpk, msg, 1)) |
||||
# k_1 == 0 or k_2 == 0 cannot occur except with negligible probability. |
||||
assert k_1 != 0 |
||||
assert k_2 != 0 |
||||
|
||||
R_s1 = k_1 * G |
||||
R_s2 = k_2 * G |
||||
assert not R_s1.infinity |
||||
assert not R_s2.infinity |
||||
pubnonce = cbytes(R_s1) + cbytes(R_s2) |
||||
secnonce = bytearray(k_1.to_bytes() + k_2.to_bytes()) |
||||
try: |
||||
aggnonce = nonce_agg([pubnonce, aggothernonce], [my_id, AGGREGATOR_ID]) |
||||
except Exception: |
||||
# Since `pubnonce` can never be invalid, blame aggregator's pubnonce. |
||||
# REVIEW: should we introduce an unknown participant or aggregator error? |
||||
raise InvalidContributionError(AGGREGATOR_ID, "aggothernonce") |
||||
session_ctx = SessionContext(aggnonce, ids, pubshares, tweaks, is_xonly, msg) |
||||
psig = sign(secnonce, secshare, my_id, session_ctx) |
||||
return (pubnonce, psig) |
||||
|
||||
def partial_sig_verify(psig: bytes, ids: List[int], pubnonces: List[bytes], pubshares: List[PlainPk], tweaks: List[bytes], is_xonly: List[bool], msg: bytes, i: int) -> bool: |
||||
if not len(ids) == len(pubnonces) == len(pubshares): |
||||
raise ValueError('The ids, pubnonces and pubshares arrays must have the same length.') |
||||
if len(tweaks) != len(is_xonly): |
||||
raise ValueError('The tweaks and is_xonly arrays must have the same length.') |
||||
aggnonce = nonce_agg(pubnonces, ids) |
||||
session_ctx = SessionContext(aggnonce, ids, pubshares, tweaks, is_xonly, msg) |
||||
return partial_sig_verify_internal(psig, ids[i], pubnonces[i], pubshares[i], session_ctx) |
||||
|
||||
#todo: catch `cpoint`` ValueError and return false |
||||
def partial_sig_verify_internal(psig: bytes, my_id: int, pubnonce: bytes, pubshare: bytes, session_ctx: SessionContext) -> bool: |
||||
(Q, gacc, _, b, R, e) = get_session_values(session_ctx) |
||||
try: |
||||
s = Scalar.from_bytes_checked(psig) |
||||
except ValueError: |
||||
return False |
||||
if not session_has_signer_pubshare(session_ctx, pubshare): |
||||
return False |
||||
R_s1 = cpoint(pubnonce[0:33]) |
||||
R_s2 = cpoint(pubnonce[33:66]) |
||||
Re_s_ = R_s1 + b * R_s2 |
||||
Re_s = Re_s_ if R.has_even_y() else -Re_s_ |
||||
P = cpoint(pubshare) |
||||
if P is None: |
||||
return False |
||||
a = get_session_interpolating_value(session_ctx, my_id) |
||||
g = Scalar(1) if Q.has_even_y() else Scalar(-1) |
||||
g_ = g * gacc |
||||
return s * G == Re_s + (e * a * g_) * P |
||||
|
||||
def partial_sig_agg(psigs: List[bytes], ids: List[int], session_ctx: SessionContext) -> bytes: |
||||
assert AGGREGATOR_ID not in ids |
||||
if len(psigs) != len(ids): |
||||
raise ValueError('The psigs and ids arrays must have the same length.') |
||||
(Q, _, tacc, _, R, e) = get_session_values(session_ctx) |
||||
s = Scalar(0) |
||||
for my_id, psig in zip(ids, psigs): |
||||
s_i = int_from_bytes(psig) |
||||
try: |
||||
s_i = Scalar.from_bytes_checked(psig) |
||||
except ValueError: |
||||
raise InvalidContributionError(my_id, "psig") |
||||
s = s + s_i |
||||
g = Scalar(1) if Q.has_even_y() else Scalar(-1) |
||||
s = s + e * g * tacc |
||||
return xbytes(R) + s.to_bytes() |
||||
@ -0,0 +1,10 @@
|
||||
# Changelog |
||||
|
||||
All notable changes to this project will be documented in this file. |
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), |
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). |
||||
|
||||
## [1.0.0] - 2025-03-31 |
||||
|
||||
Initial release. |
||||
@ -0,0 +1,23 @@
|
||||
The MIT License (MIT) |
||||
|
||||
Copyright (c) 2009-2024 The Bitcoin Core developers |
||||
Copyright (c) 2009-2024 Bitcoin Developers |
||||
Copyright (c) 2025- The secp256k1lab Developers |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in |
||||
all copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN |
||||
THE SOFTWARE. |
||||
@ -0,0 +1,13 @@
|
||||
secp256k1lab |
||||
============ |
||||
|
||||
 |
||||
|
||||
An INSECURE implementation of the secp256k1 elliptic curve and related cryptographic schemes written in Python, intended for prototyping, experimentation and education. |
||||
|
||||
Features: |
||||
* Low-level secp256k1 field and group arithmetic. |
||||
* Schnorr signing/verification and key generation according to [BIP-340](https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki). |
||||
* ECDH key exchange. |
||||
|
||||
WARNING: The code in this library is slow and trivially vulnerable to side channel attacks. |
||||
@ -0,0 +1,73 @@
|
||||
# The following functions are based on the BIP 340 reference implementation: |
||||
# https://github.com/bitcoin/bips/blob/master/bip-0340/reference.py |
||||
|
||||
from .secp256k1 import FE, GE, G |
||||
from .util import int_from_bytes, bytes_from_int, xor_bytes, tagged_hash |
||||
|
||||
|
||||
def pubkey_gen(seckey: bytes) -> bytes: |
||||
d0 = int_from_bytes(seckey) |
||||
if not (1 <= d0 <= GE.ORDER - 1): |
||||
raise ValueError("The secret key must be an integer in the range 1..n-1.") |
||||
P = d0 * G |
||||
assert not P.infinity |
||||
return P.to_bytes_xonly() |
||||
|
||||
|
||||
def schnorr_sign( |
||||
msg: bytes, seckey: bytes, aux_rand: bytes, tag_prefix: str = "BIP0340" |
||||
) -> bytes: |
||||
d0 = int_from_bytes(seckey) |
||||
if not (1 <= d0 <= GE.ORDER - 1): |
||||
raise ValueError("The secret key must be an integer in the range 1..n-1.") |
||||
if len(aux_rand) != 32: |
||||
raise ValueError("aux_rand must be 32 bytes instead of %i." % len(aux_rand)) |
||||
P = d0 * G |
||||
assert not P.infinity |
||||
d = d0 if P.has_even_y() else GE.ORDER - d0 |
||||
t = xor_bytes(bytes_from_int(d), tagged_hash(tag_prefix + "/aux", aux_rand)) |
||||
k0 = ( |
||||
int_from_bytes(tagged_hash(tag_prefix + "/nonce", t + P.to_bytes_xonly() + msg)) |
||||
% GE.ORDER |
||||
) |
||||
if k0 == 0: |
||||
raise RuntimeError("Failure. This happens only with negligible probability.") |
||||
R = k0 * G |
||||
assert not R.infinity |
||||
k = k0 if R.has_even_y() else GE.ORDER - k0 |
||||
e = ( |
||||
int_from_bytes( |
||||
tagged_hash( |
||||
tag_prefix + "/challenge", R.to_bytes_xonly() + P.to_bytes_xonly() + msg |
||||
) |
||||
) |
||||
% GE.ORDER |
||||
) |
||||
sig = R.to_bytes_xonly() + bytes_from_int((k + e * d) % GE.ORDER) |
||||
assert schnorr_verify(msg, P.to_bytes_xonly(), sig, tag_prefix=tag_prefix) |
||||
return sig |
||||
|
||||
|
||||
def schnorr_verify( |
||||
msg: bytes, pubkey: bytes, sig: bytes, tag_prefix: str = "BIP0340" |
||||
) -> bool: |
||||
if len(pubkey) != 32: |
||||
raise ValueError("The public key must be a 32-byte array.") |
||||
if len(sig) != 64: |
||||
raise ValueError("The signature must be a 64-byte array.") |
||||
try: |
||||
P = GE.from_bytes_xonly(pubkey) |
||||
except ValueError: |
||||
return False |
||||
r = int_from_bytes(sig[0:32]) |
||||
s = int_from_bytes(sig[32:64]) |
||||
if (r >= FE.SIZE) or (s >= GE.ORDER): |
||||
return False |
||||
e = ( |
||||
int_from_bytes(tagged_hash(tag_prefix + "/challenge", sig[0:32] + pubkey + msg)) |
||||
% GE.ORDER |
||||
) |
||||
R = s * G - e * P |
||||
if R.infinity or (not R.has_even_y()) or (R.x != r): |
||||
return False |
||||
return True |
||||
@ -0,0 +1,16 @@
|
||||
import hashlib |
||||
|
||||
from .secp256k1 import GE, Scalar |
||||
|
||||
|
||||
def ecdh_compressed_in_raw_out(seckey: bytes, pubkey: bytes) -> GE: |
||||
"""TODO""" |
||||
shared_secret = Scalar.from_bytes_checked(seckey) * GE.from_bytes_compressed(pubkey) |
||||
assert not shared_secret.infinity # prime-order group |
||||
return shared_secret |
||||
|
||||
|
||||
def ecdh_libsecp256k1(seckey: bytes, pubkey: bytes) -> bytes: |
||||
"""TODO""" |
||||
shared_secret = ecdh_compressed_in_raw_out(seckey, pubkey) |
||||
return hashlib.sha256(shared_secret.to_bytes_compressed()).digest() |
||||
@ -0,0 +1,15 @@
|
||||
from .secp256k1 import GE, G |
||||
from .util import int_from_bytes |
||||
|
||||
# The following function is based on the BIP 327 reference implementation |
||||
# https://github.com/bitcoin/bips/blob/master/bip-0327/reference.py |
||||
|
||||
|
||||
# Return the plain public key corresponding to a given secret key |
||||
def pubkey_gen_plain(seckey: bytes) -> bytes: |
||||
d0 = int_from_bytes(seckey) |
||||
if not (1 <= d0 <= GE.ORDER - 1): |
||||
raise ValueError("The secret key must be an integer in the range 1..n-1.") |
||||
P = d0 * G |
||||
assert not P.infinity |
||||
return P.to_bytes_compressed() |
||||
@ -0,0 +1,454 @@
|
||||
# Copyright (c) 2022-2023 The Bitcoin Core developers |
||||
# Distributed under the MIT software license, see the accompanying |
||||
# file COPYING or http://www.opensource.org/licenses/mit-license.php. |
||||
|
||||
"""Test-only implementation of low-level secp256k1 field and group arithmetic |
||||
|
||||
It is designed for ease of understanding, not performance. |
||||
|
||||
WARNING: This code is slow and trivially vulnerable to side channel attacks. Do not use for |
||||
anything but tests. |
||||
|
||||
Exports: |
||||
* FE: class for secp256k1 field elements |
||||
* GE: class for secp256k1 group elements |
||||
* G: the secp256k1 generator point |
||||
""" |
||||
|
||||
# TODO Docstrings of methods still say "field element" |
||||
class APrimeFE: |
||||
"""Objects of this class represent elements of a prime field. |
||||
|
||||
They are represented internally in numerator / denominator form, in order to delay inversions. |
||||
""" |
||||
|
||||
# The size of the field (also its modulus and characteristic). |
||||
SIZE: int |
||||
|
||||
def __init__(self, a=0, b=1): |
||||
"""Initialize a field element a/b; both a and b can be ints or field elements.""" |
||||
if isinstance(a, type(self)): |
||||
num = a._num |
||||
den = a._den |
||||
else: |
||||
num = a % self.SIZE |
||||
den = 1 |
||||
if isinstance(b, type(self)): |
||||
den = (den * b._num) % self.SIZE |
||||
num = (num * b._den) % self.SIZE |
||||
else: |
||||
den = (den * b) % self.SIZE |
||||
assert den != 0 |
||||
if num == 0: |
||||
den = 1 |
||||
self._num = num |
||||
self._den = den |
||||
|
||||
def __add__(self, a): |
||||
"""Compute the sum of two field elements (second may be int).""" |
||||
if isinstance(a, type(self)): |
||||
return type(self)(self._num * a._den + self._den * a._num, self._den * a._den) |
||||
if isinstance(a, int): |
||||
return type(self)(self._num + self._den * a, self._den) |
||||
return NotImplemented |
||||
|
||||
def __radd__(self, a): |
||||
"""Compute the sum of an integer and a field element.""" |
||||
return type(self)(a) + self |
||||
|
||||
@classmethod |
||||
# REVIEW This should be |
||||
# def sum(cls, *es: Iterable[Self]) -> Self: |
||||
# but Self needs the typing_extension package on Python <= 3.12. |
||||
def sum(cls, *es): |
||||
"""Compute the sum of field elements. |
||||
|
||||
sum(a, b, c, ...) is identical to (0 + a + b + c + ...).""" |
||||
return sum(es, start=cls(0)) |
||||
|
||||
def __sub__(self, a): |
||||
"""Compute the difference of two field elements (second may be int).""" |
||||
if isinstance(a, type(self)): |
||||
return type(self)(self._num * a._den - self._den * a._num, self._den * a._den) |
||||
if isinstance(a, int): |
||||
return type(self)(self._num - self._den * a, self._den) |
||||
return NotImplemented |
||||
|
||||
def __rsub__(self, a): |
||||
"""Compute the difference of an integer and a field element.""" |
||||
return type(self)(a) - self |
||||
|
||||
def __mul__(self, a): |
||||
"""Compute the product of two field elements (second may be int).""" |
||||
if isinstance(a, type(self)): |
||||
return type(self)(self._num * a._num, self._den * a._den) |
||||
if isinstance(a, int): |
||||
return type(self)(self._num * a, self._den) |
||||
return NotImplemented |
||||
|
||||
def __rmul__(self, a): |
||||
"""Compute the product of an integer with a field element.""" |
||||
return type(self)(a) * self |
||||
|
||||
def __truediv__(self, a): |
||||
"""Compute the ratio of two field elements (second may be int).""" |
||||
if isinstance(a, type(self)) or isinstance(a, int): |
||||
return type(self)(self, a) |
||||
return NotImplemented |
||||
|
||||
def __pow__(self, a): |
||||
"""Raise a field element to an integer power.""" |
||||
return type(self)(pow(self._num, a, self.SIZE), pow(self._den, a, self.SIZE)) |
||||
|
||||
def __neg__(self): |
||||
"""Negate a field element.""" |
||||
return type(self)(-self._num, self._den) |
||||
|
||||
def __int__(self): |
||||
"""Convert a field element to an integer in range 0..SIZE-1. The result is cached.""" |
||||
if self._den != 1: |
||||
self._num = (self._num * pow(self._den, -1, self.SIZE)) % self.SIZE |
||||
self._den = 1 |
||||
return self._num |
||||
|
||||
def sqrt(self): |
||||
"""Compute the square root of a field element if it exists (None otherwise).""" |
||||
raise NotImplementedError |
||||
|
||||
def is_square(self): |
||||
"""Determine if this field element has a square root.""" |
||||
# A more efficient algorithm is possible here (Jacobi symbol). |
||||
return self.sqrt() is not None |
||||
|
||||
def is_even(self): |
||||
"""Determine whether this field element, represented as integer in 0..SIZE-1, is even.""" |
||||
return int(self) & 1 == 0 |
||||
|
||||
def __eq__(self, a): |
||||
"""Check whether two field elements are equal (second may be an int).""" |
||||
if isinstance(a, type(self)): |
||||
return (self._num * a._den - self._den * a._num) % self.SIZE == 0 |
||||
return (self._num - self._den * a) % self.SIZE == 0 |
||||
|
||||
def to_bytes(self): |
||||
"""Convert a field element to a 32-byte array (BE byte order).""" |
||||
return int(self).to_bytes(32, 'big') |
||||
|
||||
@classmethod |
||||
def from_int_checked(cls, v): |
||||
"""Convert an integer to a field element (no overflow allowed).""" |
||||
if v >= cls.SIZE: |
||||
raise ValueError |
||||
return cls(v) |
||||
|
||||
@classmethod |
||||
def from_int_wrapping(cls, v): |
||||
"""Convert an integer to a field element (reduced modulo SIZE).""" |
||||
return cls(v % cls.SIZE) |
||||
|
||||
@classmethod |
||||
def from_bytes_checked(cls, b): |
||||
"""Convert a 32-byte array to a field element (BE byte order, no overflow allowed).""" |
||||
v = int.from_bytes(b, 'big') |
||||
return cls.from_int_checked(v) |
||||
|
||||
@classmethod |
||||
def from_bytes_wrapping(cls, b): |
||||
"""Convert a 32-byte array to a field element (BE byte order, reduced modulo SIZE).""" |
||||
v = int.from_bytes(b, 'big') |
||||
return cls.from_int_wrapping(v) |
||||
|
||||
def __str__(self): |
||||
"""Convert this field element to a 64 character hex string.""" |
||||
return f"{int(self):064x}" |
||||
|
||||
def __repr__(self): |
||||
"""Get a string representation of this field element.""" |
||||
return f"{type(self).__qualname__}(0x{int(self):x})" |
||||
|
||||
|
||||
class FE(APrimeFE): |
||||
SIZE = 2**256 - 2**32 - 977 |
||||
|
||||
def sqrt(self): |
||||
# Due to the fact that our modulus p is of the form (p % 4) == 3, the Tonelli-Shanks |
||||
# algorithm (https://en.wikipedia.org/wiki/Tonelli-Shanks_algorithm) is simply |
||||
# raising the argument to the power (p + 1) / 4. |
||||
|
||||
# To see why: (p-1) % 2 = 0, so 2 divides the order of the multiplicative group, |
||||
# and thus only half of the non-zero field elements are squares. An element a is |
||||
# a (nonzero) square when Euler's criterion, a^((p-1)/2) = 1 (mod p), holds. We're |
||||
# looking for x such that x^2 = a (mod p). Given a^((p-1)/2) = 1, that is equivalent |
||||
# to x^2 = a^(1 + (p-1)/2) mod p. As (1 + (p-1)/2) is even, this is equivalent to |
||||
# x = a^((1 + (p-1)/2)/2) mod p, or x = a^((p+1)/4) mod p. |
||||
v = int(self) |
||||
s = pow(v, (self.SIZE + 1) // 4, self.SIZE) |
||||
if s**2 % self.SIZE == v: |
||||
return type(self)(s) |
||||
return None |
||||
|
||||
|
||||
class Scalar(APrimeFE): |
||||
"""TODO Docstring""" |
||||
SIZE = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 |
||||
|
||||
|
||||
class GE: |
||||
"""Objects of this class represent secp256k1 group elements (curve points or infinity) |
||||
|
||||
GE objects are immutable. |
||||
|
||||
Normal points on the curve have fields: |
||||
* x: the x coordinate (a field element) |
||||
* y: the y coordinate (a field element, satisfying y^2 = x^3 + 7) |
||||
* infinity: False |
||||
|
||||
The point at infinity has field: |
||||
* infinity: True |
||||
""" |
||||
|
||||
# TODO The following two class attributes should probably be just getters as |
||||
# classmethods to enforce immutability. Unfortunately Python makes it hard |
||||
# to create "classproperties". `G` could then also be just a classmethod. |
||||
|
||||
# Order of the group (number of points on the curve, plus 1 for infinity) |
||||
ORDER = Scalar.SIZE |
||||
|
||||
# Number of valid distinct x coordinates on the curve. |
||||
ORDER_HALF = ORDER // 2 |
||||
|
||||
@property |
||||
def infinity(self): |
||||
"""Whether the group element is the point at infinity.""" |
||||
return self._infinity |
||||
|
||||
@property |
||||
def x(self): |
||||
"""The x coordinate (a field element) of a non-infinite group element.""" |
||||
assert not self.infinity |
||||
return self._x |
||||
|
||||
@property |
||||
def y(self): |
||||
"""The y coordinate (a field element) of a non-infinite group element.""" |
||||
assert not self.infinity |
||||
return self._y |
||||
|
||||
def __init__(self, x=None, y=None): |
||||
"""Initialize a group element with specified x and y coordinates, or infinity.""" |
||||
if x is None: |
||||
# Initialize as infinity. |
||||
assert y is None |
||||
self._infinity = True |
||||
else: |
||||
# Initialize as point on the curve (and check that it is). |
||||
fx = FE(x) |
||||
fy = FE(y) |
||||
assert fy**2 == fx**3 + 7 |
||||
self._infinity = False |
||||
self._x = fx |
||||
self._y = fy |
||||
|
||||
def __add__(self, a): |
||||
"""Add two group elements together.""" |
||||
# Deal with infinity: a + infinity == infinity + a == a. |
||||
if self.infinity: |
||||
return a |
||||
if a.infinity: |
||||
return self |
||||
if self.x == a.x: |
||||
if self.y != a.y: |
||||
# A point added to its own negation is infinity. |
||||
assert self.y + a.y == 0 |
||||
return GE() |
||||
else: |
||||
# For identical inputs, use the tangent (doubling formula). |
||||
lam = (3 * self.x**2) / (2 * self.y) |
||||
else: |
||||
# For distinct inputs, use the line through both points (adding formula). |
||||
lam = (self.y - a.y) / (self.x - a.x) |
||||
# Determine point opposite to the intersection of that line with the curve. |
||||
x = lam**2 - (self.x + a.x) |
||||
y = lam * (self.x - x) - self.y |
||||
return GE(x, y) |
||||
|
||||
@staticmethod |
||||
def sum(*ps): |
||||
"""Compute the sum of group elements. |
||||
|
||||
GE.sum(a, b, c, ...) is identical to (GE() + a + b + c + ...).""" |
||||
return sum(ps, start=GE()) |
||||
|
||||
@staticmethod |
||||
def batch_mul(*aps): |
||||
"""Compute a (batch) scalar group element multiplication. |
||||
|
||||
GE.batch_mul((a1, p1), (a2, p2), (a3, p3)) is identical to a1*p1 + a2*p2 + a3*p3, |
||||
but more efficient.""" |
||||
# Reduce all the scalars modulo order first (so we can deal with negatives etc). |
||||
naps = [(int(a), p) for a, p in aps] |
||||
# Start with point at infinity. |
||||
r = GE() |
||||
# Iterate over all bit positions, from high to low. |
||||
for i in range(255, -1, -1): |
||||
# Double what we have so far. |
||||
r = r + r |
||||
# Add then add the points for which the corresponding scalar bit is set. |
||||
for (a, p) in naps: |
||||
if (a >> i) & 1: |
||||
r += p |
||||
return r |
||||
|
||||
def __rmul__(self, a): |
||||
"""Multiply an integer with a group element.""" |
||||
if self == G: |
||||
return FAST_G.mul(Scalar(a)) |
||||
return GE.batch_mul((Scalar(a), self)) |
||||
|
||||
def __neg__(self): |
||||
"""Compute the negation of a group element.""" |
||||
if self.infinity: |
||||
return self |
||||
return GE(self.x, -self.y) |
||||
|
||||
def __sub__(self, a): |
||||
"""Subtract a group element from another.""" |
||||
return self + (-a) |
||||
|
||||
def __eq__(self, a): |
||||
"""Check if two group elements are equal.""" |
||||
return (self - a).infinity |
||||
|
||||
def has_even_y(self): |
||||
"""Determine whether a non-infinity group element has an even y coordinate.""" |
||||
assert not self.infinity |
||||
return self.y.is_even() |
||||
|
||||
def to_bytes_compressed(self): |
||||
"""Convert a non-infinite group element to 33-byte compressed encoding.""" |
||||
assert not self.infinity |
||||
return bytes([3 - self.y.is_even()]) + self.x.to_bytes() |
||||
|
||||
def to_bytes_compressed_with_infinity(self): |
||||
"""Convert a group element to 33-byte compressed encoding, mapping infinity to zeros.""" |
||||
if self.infinity: |
||||
return 33 * b"\x00" |
||||
return self.to_bytes_compressed() |
||||
|
||||
def to_bytes_uncompressed(self): |
||||
"""Convert a non-infinite group element to 65-byte uncompressed encoding.""" |
||||
assert not self.infinity |
||||
return b'\x04' + self.x.to_bytes() + self.y.to_bytes() |
||||
|
||||
def to_bytes_xonly(self): |
||||
"""Convert (the x coordinate of) a non-infinite group element to 32-byte xonly encoding.""" |
||||
assert not self.infinity |
||||
return self.x.to_bytes() |
||||
|
||||
@staticmethod |
||||
def lift_x(x): |
||||
"""Return group element with specified field element as x coordinate (and even y).""" |
||||
y = (FE(x)**3 + 7).sqrt() |
||||
if y is None: |
||||
raise ValueError |
||||
if not y.is_even(): |
||||
y = -y |
||||
return GE(x, y) |
||||
|
||||
@staticmethod |
||||
def from_bytes_compressed(b): |
||||
"""Convert a compressed to a group element.""" |
||||
assert len(b) == 33 |
||||
if b[0] != 2 and b[0] != 3: |
||||
raise ValueError |
||||
x = FE.from_bytes_checked(b[1:]) |
||||
r = GE.lift_x(x) |
||||
if b[0] == 3: |
||||
r = -r |
||||
return r |
||||
|
||||
@staticmethod |
||||
def from_bytes_uncompressed(b): |
||||
"""Convert an uncompressed to a group element.""" |
||||
assert len(b) == 65 |
||||
if b[0] != 4: |
||||
raise ValueError |
||||
x = FE.from_bytes_checked(b[1:33]) |
||||
y = FE.from_bytes_checked(b[33:]) |
||||
if y**2 != x**3 + 7: |
||||
raise ValueError |
||||
return GE(x, y) |
||||
|
||||
@staticmethod |
||||
def from_bytes(b): |
||||
"""Convert a compressed or uncompressed encoding to a group element.""" |
||||
assert len(b) in (33, 65) |
||||
if len(b) == 33: |
||||
return GE.from_bytes_compressed(b) |
||||
else: |
||||
return GE.from_bytes_uncompressed(b) |
||||
|
||||
@staticmethod |
||||
def from_bytes_xonly(b): |
||||
"""Convert a point given in xonly encoding to a group element.""" |
||||
assert len(b) == 32 |
||||
x = FE.from_bytes_checked(b) |
||||
r = GE.lift_x(x) |
||||
return r |
||||
|
||||
@staticmethod |
||||
def is_valid_x(x): |
||||
"""Determine whether the provided field element is a valid X coordinate.""" |
||||
return (FE(x)**3 + 7).is_square() |
||||
|
||||
def __str__(self): |
||||
"""Convert this group element to a string.""" |
||||
if self.infinity: |
||||
return "(inf)" |
||||
return f"({self.x},{self.y})" |
||||
|
||||
def __repr__(self): |
||||
"""Get a string representation for this group element.""" |
||||
if self.infinity: |
||||
return "GE()" |
||||
return f"GE(0x{int(self.x):x},0x{int(self.y):x})" |
||||
|
||||
def __hash__(self): |
||||
"""Compute a non-cryptographic hash of the group element.""" |
||||
if self.infinity: |
||||
return 0 # 0 is not a valid x coordinate |
||||
return int(self.x) |
||||
|
||||
|
||||
# The secp256k1 generator point |
||||
G = GE.lift_x(0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798) |
||||
|
||||
|
||||
class FastGEMul: |
||||
"""Table for fast multiplication with a constant group element. |
||||
|
||||
Speed up scalar multiplication with a fixed point P by using a precomputed lookup table with |
||||
its powers of 2: |
||||
|
||||
table = [P, 2*P, 4*P, (2^3)*P, (2^4)*P, ..., (2^255)*P] |
||||
|
||||
During multiplication, the points corresponding to each bit set in the scalar are added up, |
||||
i.e. on average ~128 point additions take place. |
||||
""" |
||||
|
||||
def __init__(self, p): |
||||
self.table = [p] # table[i] = (2^i) * p |
||||
for _ in range(255): |
||||
p = p + p |
||||
self.table.append(p) |
||||
|
||||
def mul(self, a): |
||||
result = GE() |
||||
a = int(a) |
||||
for bit in range(a.bit_length()): |
||||
if a & (1 << bit): |
||||
result += self.table[bit] |
||||
return result |
||||
|
||||
# Precomputed table with multiples of G for fast multiplication |
||||
FAST_G = FastGEMul(G) |
||||
@ -0,0 +1,24 @@
|
||||
import hashlib |
||||
|
||||
|
||||
# This implementation can be sped up by storing the midstate after hashing |
||||
# tag_hash instead of rehashing it all the time. |
||||
def tagged_hash(tag: str, msg: bytes) -> bytes: |
||||
tag_hash = hashlib.sha256(tag.encode()).digest() |
||||
return hashlib.sha256(tag_hash + tag_hash + msg).digest() |
||||
|
||||
|
||||
def bytes_from_int(x: int) -> bytes: |
||||
return x.to_bytes(32, byteorder="big") |
||||
|
||||
|
||||
def xor_bytes(b0: bytes, b1: bytes) -> bytes: |
||||
return bytes(x ^ y for (x, y) in zip(b0, b1)) |
||||
|
||||
|
||||
def int_from_bytes(b: bytes) -> int: |
||||
return int.from_bytes(b, byteorder="big") |
||||
|
||||
|
||||
def hash_sha256(b: bytes) -> bytes: |
||||
return hashlib.sha256(b).digest() |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue