Sign the served config files (#76)

* feat(config): sign the served config files

signed/targets.json gives the sha256 of every JSON file the wallet may
fetch, signed with an Ed25519 targets key. signed/root.json names that
key and three sr25519 root keys (wallet accounts), two of which can
replace it; the wallet will ship this first root.

scripts/signed_config.py writes the root, signs the targets list and
checks it. Code Quality now refuses a tree whose served files do not
match the signed list, and runs weekly so an expiring signature shows a
month ahead.

* fix(config): sign the test fixtures and follow published roots

The wallet refuses any JSON file from this repository that the signed
list does not give, and its instrumented tests read tests/, so those
files are signed too.

A root after the first is written to signed/root.N.json, where the
wallet looks for it; sign and check use the newest published root.
This commit is contained in:
SatoshiQaziMuhammed authored and GitHub committed 2026-11-01 04:41:22 +04:00
1 parent e50f051214
commit c84494c22f
5 files changed
+312

No files matched your search

+24
View File
@@ -5,6 +5,9 @@ on:
branches: [main]
pull_request:
branches: [main]
# Weekly, so a signature running out shows here a month before the wallet stops trusting it.
schedule:
- cron: '0 7 * * 1'
# Read-only token: Code Quality never writes through GITHUB_TOKEN.
@@ -64,6 +67,27 @@ jobs:
exit $ERRORS
signed-config:
name: Signed Config
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install cryptography
run: pip install cryptography
# The wallet refuses a config file whose sha256 signed/targets.json does not give, so
# a change that was not re-signed must not reach master.
- name: Targets signed and matching every served file
run: python3 scripts/signed_config.py check
chain-integrity:
name: Chain Config Integrity
runs-on: ubuntu-latest
+14
View File
@@ -55,6 +55,20 @@ These configurations are fetched by Pezkuwi Wallet at runtime from GitHub raw UR
https://raw.githubusercontent.com/pezkuwichain/pezkuwi-wallet-utils/master/chains/{network}/chains.json
```
## Signed config
`signed/targets.json` gives the sha256 of every JSON file the wallet may fetch, signed with the targets
key; `signed/root.json` names that key and the three root keys, two of which can replace it. The wallet
carries the first root and refuses a file whose hash the signed list does not give, so a change to any
served JSON file is re-signed in the same pull request:
```
python3 scripts/signed_config.py sign --key <targets seed>
```
Code Quality refuses a tree whose files do not match the signed list, and its weekly run fails a month
before a signature expires.
## Contributing
1. Fork this repository
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""Signed config metadata for the wallet, after TUF.
signed/root.json names the keys: a threshold of root keys may replace the root, the targets key signs
signed/targets.json, which gives the sha256 of every config file the wallet may load. The wallet ships the
first root inside the APK and refuses a config file whose hash the signed targets list does not give.
Every metadata file is an envelope {"signed": base64(payload), "signatures": [{"keyid", "sig"}]}; keyid is the
hex sha256 of the 32-byte public key. An ed25519 key signs the payload bytes. An sr25519 key is a person's
wallet account and signs through the wallet's message signing, which wraps the bytes as <Bytes>payload</Bytes>.
signed_config.py root --version N --expires-days D --root-threshold T --root ADDR... --targets-pub FILE
signed_config.py sign --key SEED_FILE [--expires-days D]
signed_config.py check [--min-days D]
"""
import argparse
import base64
import hashlib
import json
import subprocess
import sys
import time
from pathlib import Path
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
Ed25519PrivateKey,
Ed25519PublicKey,
)
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
REPO = Path(__file__).resolve().parent.parent
ROOT_FILE = REPO / "signed" / "root.json"
TARGETS_FILE = REPO / "signed" / "targets.json"
# Everything the wallet may fetch from this repository is JSON outside these trees; the wallet
# refuses any other JSON file from here, so a tree is only left out when nothing reads it.
UNSERVED = ("nova-base/", "pezkuwi-overlay/", "docs/", "signed/")
BASE58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
DAY = 86400
def key_id(public: bytes) -> str:
return hashlib.sha256(public).hexdigest()
def ss58_public_key(address: str) -> bytes:
"""The 32-byte account key of a single-byte-prefix SS58 address, checksum verified."""
n = 0
for c in address:
n = n * 58 + BASE58.index(c)
raw = n.to_bytes(35, "big")
if raw[0] >= 64:
raise ValueError(f"{address}: only single-byte SS58 prefixes are handled")
checksum = hashlib.blake2b(b"SS58PRE" + raw[:33], digest_size=64).digest()[:2]
if checksum != raw[33:]:
raise ValueError(f"{address}: bad SS58 checksum")
return raw[1:33]
def canonical(payload: dict) -> bytes:
return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
def envelope(payload: bytes, signatures: list) -> str:
return (
json.dumps(
{"signed": base64.b64encode(payload).decode(), "signatures": signatures},
indent=1,
)
+ "\n"
)
def read_envelope(path: Path):
env = json.loads(path.read_text())
payload = base64.b64decode(env["signed"])
return payload, json.loads(payload), env["signatures"]
def root_file(version: int) -> Path:
"""Root 1 is the one the APK carries; each later root is published beside it, for the wallet to follow."""
return ROOT_FILE if version == 1 else ROOT_FILE.with_name(f"root.{version}.json")
def current_root() -> dict:
"""The newest published root. Signatures on later roots are the wallet's to check, not this tool's."""
_, root, _ = read_envelope(ROOT_FILE)
while root_file(root["version"] + 1).exists():
_, root, _ = read_envelope(root_file(root["version"] + 1))
return root
def served_files() -> dict:
"""Repository path -> sha256 of every tracked JSON file the wallet may fetch."""
tracked = subprocess.run(
["git", "-C", str(REPO), "ls-files", "-z", "*.json"],
check=True,
capture_output=True,
).stdout.split(b"\0")
files = {}
for name in sorted(p.decode() for p in tracked if p):
if not name.startswith(UNSERVED):
files[name] = hashlib.sha256((REPO / name).read_bytes()).hexdigest()
return files
def cmd_root(args):
keys = {}
root_ids = []
for address in args.root:
public = ss58_public_key(address)
keys[key_id(public)] = {
"scheme": "sr25519",
"public": base64.b64encode(public).decode(),
}
root_ids.append(key_id(public))
targets_public = base64.b64decode(Path(args.targets_pub).read_text().strip())
if len(targets_public) != 32:
sys.exit("targets public key must be 32 bytes")
keys[key_id(targets_public)] = {
"scheme": "ed25519",
"public": base64.b64encode(targets_public).decode(),
}
if not 1 <= args.root_threshold <= len(root_ids):
sys.exit("root threshold out of range")
payload = {
"type": "root",
"version": args.version,
"expires": int(time.time()) + args.expires_days * DAY,
"keys": keys,
"root": {"keyids": sorted(root_ids), "threshold": args.root_threshold},
"targets": {"keyids": [key_id(targets_public)], "threshold": 1},
}
path = root_file(args.version)
path.parent.mkdir(exist_ok=True)
# The first root is trusted because the APK carries it; a later one needs the signatures of a threshold
# of the previous root keys and of its own, collected from the holders' wallets and added to this file
# before it is published.
path.write_text(envelope(canonical(payload), []))
print(f"wrote {path.relative_to(REPO)} version {args.version}")
def cmd_sign(args):
private = Ed25519PrivateKey.from_private_bytes(
bytes.fromhex(Path(args.key).read_text().strip())
)
public = private.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
root = current_root()
if key_id(public) not in root["targets"]["keyids"]:
sys.exit(f"key {key_id(public)} is not a targets key of root {root['version']}")
version = 1
if TARGETS_FILE.exists():
version = read_envelope(TARGETS_FILE)[1]["version"] + 1
payload = canonical(
{
"type": "targets",
"version": version,
"expires": int(time.time()) + args.expires_days * DAY,
"files": served_files(),
}
)
signature = private.sign(payload)
TARGETS_FILE.write_text(
envelope(
payload,
[{"keyid": key_id(public), "sig": base64.b64encode(signature).decode()}],
)
)
print(
f"signed {TARGETS_FILE.relative_to(REPO)} version {version}, {len(json.loads(payload)['files'])} files"
)
def cmd_check(args):
errors = []
root = current_root()
payload, targets, signatures = read_envelope(TARGETS_FILE)
now = int(time.time())
valid = set()
for sig in signatures:
key = root["keys"].get(sig["keyid"])
if (
sig["keyid"] not in root["targets"]["keyids"]
or key is None
or key["scheme"] != "ed25519"
):
continue
try:
Ed25519PublicKey.from_public_bytes(base64.b64decode(key["public"])).verify(
base64.b64decode(sig["sig"]), payload
)
valid.add(sig["keyid"])
except InvalidSignature:
pass
if len(valid) < root["targets"]["threshold"]:
errors.append(
f"targets: {len(valid)} of {root['targets']['threshold']} required signatures"
)
for what, meta in (("root", root), ("targets", targets)):
days = (meta["expires"] - now) // DAY
if days < args.min_days:
errors.append(
f"{what} {meta['version']} expires in {days} days (re-sign before {args.min_days})"
)
signed = targets["files"]
actual = served_files()
for name in sorted(actual.keys() | signed.keys()):
if name not in signed:
errors.append(f"{name}: not in targets")
elif name not in actual:
errors.append(f"{name}: in targets but not in the tree")
elif signed[name] != actual[name]:
errors.append(f"{name}: hash differs from targets")
for e in errors:
print(f"::error::{e}")
if errors:
print("Run: python3 scripts/signed_config.py sign --key <targets seed>")
sys.exit(1)
print(
f"targets {targets['version']} signed, {len(signed)} files match, root {root['version']}"
)
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
sub = parser.add_subparsers(dest="command", required=True)
root = sub.add_parser("root")
root.add_argument("--version", type=int, required=True)
root.add_argument("--expires-days", type=int, required=True)
root.add_argument("--root-threshold", type=int, required=True)
root.add_argument("--root", nargs="+", required=True)
root.add_argument("--targets-pub", required=True)
root.set_defaults(run=cmd_root)
sign = sub.add_parser("sign")
sign.add_argument("--key", required=True)
sign.add_argument("--expires-days", type=int, default=365)
sign.set_defaults(run=cmd_sign)
check = sub.add_parser("check")
check.add_argument("--min-days", type=int, default=30)
check.set_defaults(run=cmd_check)
args = parser.parse_args()
args.run(args)
if __name__ == "__main__":
main()
+4
View File
@@ -0,0 +1,4 @@
{
"signed": "eyJleHBpcmVzIjoxOTUxMTcyODAxLCJrZXlzIjp7IjM3ZDE4Mzc0YmYwNjc0MDQyMTZkZjViYTgzZDhhN2MzMDk4MTgzNTMyMDBmY2Q2NzM3ODcyMmZlMzMxYjAwOGYiOnsicHVibGljIjoia0I2VG4wZDVOeDZ6dGJEYUd5Z1hWL3JqNFlENlVZa05ZL3gySTlMQy9DVT0iLCJzY2hlbWUiOiJzcjI1NTE5In0sIjYwNjZlNmE4NzI5YzM0MDk4OWU3YzE1MGVmYmQ2M2IxZWZiOWZmMWNjYWExZTFmMzhmYTAxNWMyZjFmOWFiNWEiOnsicHVibGljIjoiWGFPSGYzT1NTWUxxNmxabUVuSTNDRlk5cmR3UGU5eWNsc0R4U1A2NEdXWT0iLCJzY2hlbWUiOiJlZDI1NTE5In0sIjlhMzYyOTY5M2VkNTM4ZDAzN2VjMzI1MjM1YWI1ZmY1ZTQ2ZDczNWE0NGQxMjcyY2I0MzJlOWI1Y2I2YjJhMDIiOnsicHVibGljIjoiOE05Q0czc1FFZzllTW4vQnpRSWV4cGVJLzVXZkNRNmFZYlhqdTRhR0puMD0iLCJzY2hlbWUiOiJzcjI1NTE5In0sImVmN2MwNDk0NWM5ZTAwN2VjMzRkZmNhNDIwZjljOTJmZWIwZGNiNGQxNTNiMWI5ZjFhNWE2OWY0YjA1NzNhNjIiOnsicHVibGljIjoiY0lnSnUyOURQTUM5M0RpTVlWZ1VHT2tEQXFpTHJiTjEvaUVRaS8ycnBYVT0iLCJzY2hlbWUiOiJzcjI1NTE5In19LCJyb290Ijp7ImtleWlkcyI6WyIzN2QxODM3NGJmMDY3NDA0MjE2ZGY1YmE4M2Q4YTdjMzA5ODE4MzUzMjAwZmNkNjczNzg3MjJmZTMzMWIwMDhmIiwiOWEzNjI5NjkzZWQ1MzhkMDM3ZWMzMjUyMzVhYjVmZjVlNDZkNzM1YTQ0ZDEyNzJjYjQzMmU5YjVjYjZiMmEwMiIsImVmN2MwNDk0NWM5ZTAwN2VjMzRkZmNhNDIwZjljOTJmZWIwZGNiNGQxNTNiMWI5ZjFhNWE2OWY0YjA1NzNhNjIiXSwidGhyZXNob2xkIjoyfSwidGFyZ2V0cyI6eyJrZXlpZHMiOlsiNjA2NmU2YTg3MjljMzQwOTg5ZTdjMTUwZWZiZDYzYjFlZmI5ZmYxY2NhYTFlMWYzOGZhMDE1YzJmMWY5YWI1YSJdLCJ0aHJlc2hvbGQiOjF9LCJ0eXBlIjoicm9vdCIsInZlcnNpb24iOjF9",
"signatures": []
}
+9
View File
@@ -0,0 +1,9 @@
{
"signed": "eyJleHBpcmVzIjoxODI1MDI5MzkzLCJmaWxlcyI6eyJhc3NldHMvZXZtL3YxL2Fzc2V0cy5qc29uIjoiYmI2ZjliYWNlMmY2Y2Q5NjBlMjYyM2Q5ZWE0MmM2OTE3MzUxOWQyZmE5YWVhNWRkMzEyNzA5Njc2ZWExMDdkYSIsImFzc2V0cy9ldm0vdjEvYXNzZXRzX2Rldi5qc29uIjoiNDJhMThhYTUxZTEwZmY0NjllOTYxMGJlMzNmOThkZjhlYWM5NjFhZTVmZDc2YWRhZTNmZThiNzhlZGVlYzIxNiIsImFzc2V0cy9ldm0vdjIvYXNzZXRzLmpzb24iOiJhZWNjZjRlZTE5ODhiOTI0OTRmMWZkZDBjYTI0OGYzYjlmYjE2NmFkZTAyNjRhM2IzZjRmYWYzZDc1MjhhYzBmIiwiYXNzZXRzL2V2bS92Mi9hc3NldHNfZGV2Lmpzb24iOiI2YmZlZDBmMzU2M2YxMDdkNDViZWM0ZjQ3MjYzYWUyYzMyMjA3ZjQ1ZGRhNmQwNmUxNDczNmFkNWM2OTYzYmYzIiwiYXNzZXRzL2V2bS92My9hc3NldHMuanNvbiI6ImM0NDc4NWRiOTkyZjE0NTA3NTFkY2FjOGI1MTVlOTg0NzRlYTA0YTMyZTE4MjgwYjBmOWEyNjNkNzNhM2RjYzUiLCJhc3NldHMvZXZtL3YzL2Fzc2V0c19kZXYuanNvbiI6ImUyMDc5Y2VjNTkxMGQ0NDE0MzBiNzc0ZmYxMjJkN2E2MGI1OTIwMDZlZjE3NmI4NDAzMTE2ZWMyMmM1YTYyNTEiLCJhc3NldHMvc2xpcDQ0Lmpzb24iOiI2MDAyM2IzN2MxMDE4NDdjNzJmMzZmMDVmNTJlNmE4Y2U0Mzk1NjU5ZDZhOTg0ZmE5YjMyYTFiMzlkYWJjODUwIiwiYmFubmVycy92Mi9jb250ZW50L2Fzc2V0cy9iYW5uZXJzLmpzb24iOiI1Yjg3NGMyZjdiNGE3YTJmZDY0Nzc2ZjVhNDI4YTljMTUzMTJjZmNjZDUyMmNlZDRjZjkzMjYxNjFjOTFiZWNiIiwiYmFubmVycy92Mi9jb250ZW50L2Fzc2V0cy9iYW5uZXJzX2Rldi5qc29uIjoiNWI4NzRjMmY3YjRhN2EyZmQ2NDc3NmY1YTQyOGE5YzE1MzEyY2ZjY2Q1MjJjZWQ0Y2Y5MzI2MTYxYzkxYmVjYiIsImJhbm5lcnMvdjIvY29udGVudC9hc3NldHMvbG9jYWxpemVkL2VuLmpzb24iOiI5OTFjOWM1ZDM5OTUyYTdhNGE2ZDk0Yjg2NzAzYTg0NWUwNDQxMTIzNjZkNzg1MDdmNmNlYjA2YzU1OGU1YWQyIiwiYmFubmVycy92Mi9jb250ZW50L2Fzc2V0cy9sb2NhbGl6ZWQva3UuanNvbiI6IjdkMTExOWY1Mzg2MWE5MTY5MzZjNmI0Njk0NDJlMDk5Mjc2ODY4OTU2NTRiYjg1NDkyZDhjNjhkNjU1NDI3MTMiLCJiYW5uZXJzL3YyL2NvbnRlbnQvYXNzZXRzL2xvY2FsaXplZC90ci5qc29uIjoiNDc2MWU3MWY5NmM1NWIwZmE2MmIxM2M2NmE0OGY1ZGM4YjU1MzhjZTcyNjNmN2JjOGVlNjVhNjg2MThlOWQ0NSIsImJhbm5lcnMvdjIvY29udGVudC9hc3NldHMvbG9jYWxpemVkX2Rldi9lbi5qc29uIjoiOTkxYzljNWQzOTk1MmE3YTRhNmQ5NGI4NjcwM2E4NDVlMDQ0MTEyMzY2ZDc4NTA3ZjZjZWIwNmM1NThlNWFkMiIsImJhbm5lcnMvdjIvY29udGVudC9hc3NldHMvbG9jYWxpemVkX2Rldi9rdS5qc29uIjoiN2QxMTE5ZjUzODYxYTkxNjkzNmM2YjQ2OTQ0MmUwOTkyNzY4Njg5NTY1NGJiODU0OTJkOGM2OGQ2NTU0MjcxMyIsImJhbm5lcnMvdjIvY29udGVudC9hc3NldHMvbG9jYWxpemVkX2Rldi90ci5qc29uIjoiNDc2MWU3MWY5NmM1NWIwZmE2MmIxM2M2NmE0OGY1ZGM4YjU1MzhjZTcyNjNmN2JjOGVlNjVhNjg2MThlOWQ0NSIsImJhbm5lcnMvdjIvY29udGVudC9kYXBwcy9iYW5uZXJzLmpzb24iOiJjZTk0NDk0ZjE3YjMzNjJjODQxOGZhYjk4OWZiMDc0NzA5YzVhMWVmYmU5Zjg0N2RjYzE0OGFiZjBkMmFjZjA5IiwiYmFubmVycy92Mi9jb250ZW50L2RhcHBzL2Jhbm5lcnNfZGV2Lmpzb24iOiJjZTk0NDk0ZjE3YjMzNjJjODQxOGZhYjk4OWZiMDc0NzA5YzVhMWVmYmU5Zjg0N2RjYzE0OGFiZjBkMmFjZjA5IiwiYmFubmVycy92Mi9jb250ZW50L2RhcHBzL2xvY2FsaXplZC9lbi5qc29uIjoiMTZhMzUyYzE0Y2QzYjMwYzVmZTlkMmI4MmY0NWYxMjhmMzUzZDEwYWZmMjExOWQwMDNjMjliNTE2MGIzNDI3YSIsImJhbm5lcnMvdjIvY29udGVudC9kYXBwcy9sb2NhbGl6ZWQva3UuanNvbiI6ImUyZjU1YTMzN2U1ZWFlMzQzOWVlNDg4ODI0NzQxMzc2OWFmMzAzNDRmMTRmMjg0Y2UxZjdlOWQ3NzA0Y2IyZDUiLCJiYW5uZXJzL3YyL2NvbnRlbnQvZGFwcHMvbG9jYWxpemVkX2Rldi9lbi5qc29uIjoiMTZhMzUyYzE0Y2QzYjMwYzVmZTlkMmI4MmY0NWYxMjhmMzUzZDEwYWZmMjExOWQwMDNjMjliNTE2MGIzNDI3YSIsImJhbm5lcnMvdjIvY29udGVudC9kYXBwcy9sb2NhbGl6ZWRfZGV2L2t1Lmpzb24iOiJlMmY1NWEzMzdlNWVhZTM0MzllZTQ4ODgyNDc0MTM3NjlhZjMwMzQ0ZjE0ZjI4NGNlMWY3ZTlkNzcwNGNiMmQ1IiwiY2hhaW5zL2NoYWlucy5qc29uIjoiMTM5OWE3Yjg2NTNhYmY3MWVkOTIxODg3ZGFmODRhN2I1ZTY0ZTcxMTUyMjA0NGFjYmQwMTk0ZWExZjRjYTYxZiIsImNoYWlucy9jaGFpbnNfZGV2Lmpzb24iOiJkNWI4NzhkNjY5NDIzODBmN2JjYWZiOTQ2YzRmYjlkOTkzNDA5NDc3YWFkMTljOTM2MDQ0N2MwNDM0NjNiY2ZjIiwiY2hhaW5zL3Blemt1d2ljaGFpbi9jaGFpbnMuanNvbiI6ImMwOWIyYmU5N2ZjMTRhOTM4ZGE1MDVhZmY2NTMyMzQxM2ExNjA2NzQwZTg1ODkxOTY4OWI5NDdkMmIyZmUxZDMiLCJjaGFpbnMvdHlwZXMvYWx0YWlyLmpzb24iOiIxNjg3ZTkyNGQyMjcxZmQ3NjhjYWI3NWQ0OTYxYTY3YzkwYTAwNTNkMTE4ZjFkOGI1YWFlY2EwM2NjOTUwMTQzIiwiY2hhaW5zL3R5cGVzL2JpZnJvc3QuanNvbiI6ImZmMmVjMjdiYzIyMzhmOTYyMWI5MTExYjExMDI1YTY1NjRiNWY2YWVhMzE4ZjE3NzVhNjAyOTUwNDliNDQ0MGEiLCJjaGFpbnMvdHlwZXMvY2FsYW1hcmkuanNvbiI6ImQ1MTUxZGRhMmQ1OWMyMjM5YTAwNjQ0YjMxMjc0NWQ2NDYyMTE0ZmE2YTQ1ZGQ1NDkxZGY4YjkyNDU4ZTE0NmIiLCJjaGFpbnMvdHlwZXMvZGVmYXVsdC5qc29uIjoiNTYxNjA5M2ZjMWI2NDY5MTZkY2E1YjljMzRjMThjZGRjMDUxMDE1NmMwYjgxNTAwOGMyODA5OGM1ZGYzYmY3MyIsImNoYWlucy90eXBlcy9lZGdld2FyZS5qc29uIjoiMjAwZTg4NTE5ZDJlMjMxOTcwMDVjODkyMzYyMGM1Zjg4NmUxMmY3NDNhYWY2YjEyOTdiNDlhOWRmMGQ2Y2NkNSIsImNoYWlucy90eXBlcy9rYXJ1cmEuanNvbiI6IjdkZTZhMjcwMWRiODQ2MmFmYmIyMmVlMGMyNmU2NGI3MDY2N2YyMTM4MTE3ZWNhMjVhNDJmMWY3Mjk1NjQ3MDQiLCJjaGFpbnMvdHlwZXMva2lsdC5qc29uIjoiYmE2N2M3OGEwNGUwYWI5YTI1ZDcxMWY2YzRjZTFiNTEyMjYyMWEyMDdiYmU1MzI1ZjdhOTE3NjlkMTkwMjMwZSIsImNoYWlucy90eXBlcy9rdXNhbWEuanNvbiI6ImZjYzE2OWRjZDQ5OThkMjM3YTAwZmE4NmI3ODI3YzM1ZjhhZTdlNDFjMDYyNmRmNjRiZmQyM2QyMTI1NGFlNWMiLCJjaGFpbnMvdHlwZXMvbW9vbmJhc2UuanNvbiI6IjFkMjVkZjc0YjNlNDIyZjcxOGYxOTdhZGUwNWNlYTZjMTdhOWM3ZWZiMmIxYjVjOTlhMGRiOWExNzAyYjlhODMiLCJjaGFpbnMvdHlwZXMvbW9vbnJpdmVyLmpzb24iOiJjY2E5OTMzNWFlMjU3MmE2ZjRmYmZhMmFhMjNkZDgxMjIxY2NmNGQ1M2M0YjNkMGE1OGUxZjAwYjRjYjRmM2MxIiwiY2hhaW5zL3R5cGVzL3BhcmFsbGVsLmpzb24iOiJiN2ZiNTIyYWFjNGZjODdkMWYyZWNkMjg4NTVlYjcyZmI3ZTEzM2Q1ZjQyYWEwNGQ1ZTJkYjI5MWUxOTdjOGVmIiwiY2hhaW5zL3R5cGVzL3Blemt1d2kuanNvbiI6IjE0NjZkOGU1NDMwNzM4ZDNmZjE1NWQ2NTdhZDlkNTE0MzUzYzIxYjMyYTBmMjc2MTg0MmE0MDkxNWU3MTJhNDMiLCJjaGFpbnMvdHlwZXMvcG9sa2Fkb3QuanNvbiI6IjNmN2ULine truncated
"signatures": [
{
"keyid": "6066e6a8729c340989e7c150efbd63b1efb9ff1ccaa1e1f38fa015c2f1f9ab5a",
"sig": "EEf5kGNPRK7iu5cbHlfiQ+mxYf2jg6gVmb7lSxkZUi2X1VafOKgXrKJi9ol1w6H2FpizXizgPTKgESdYt0A7Cw=="
}
]
}