diff --git a/canton/README.md b/canton/README.md index 61fcbd80a9..06912a32a5 100644 --- a/canton/README.md +++ b/canton/README.md @@ -780,6 +780,78 @@ This brings up: - the **guardian** configured with `--cantonRPC canton:6865` (no `--cantonReadAsParty`: the watcher uses a wildcard "any party" filter, §7.1). +### `guardianGovernance` external-party bootstrap + +[`devnet/setup_guardian_governance.sh`](devnet/setup_guardian_governance.sh) + +[`devnet/guardian_governance.canton`](devnet/guardian_governance.canton) stand up +`guardianGovernance` as an **external, decentralized-namespace party** — a +namespace owned by three guardian keys with a 2-of-3 threshold, the same pattern +the Canton Network DSO party uses (§10, §11 item 2). Concretely, the console +script: + +1. self-signs a root `NamespaceDelegation` for each of the three owner keys; +2. creates a `DecentralizedNamespaceDefinition` (2-of-3 threshold) over those + three owner namespaces, authorized by all three; +3. allocates `guardianGovernance::` under it and hosts it at + **Confirmation** permission, with the same three keys registered as its + threshold transaction-signing keys (`PartyToParticipant.partySigningKeys` — + the standalone `PartyToKeyMapping` was deprecated in Canton 3.4 in favor of + this); and +4. writes an artifacts JSON (party id, decentralized-namespace id, owner + fingerprints/public-key paths, threshold) that is the handoff to submitting + the same topology to the CN global synchronizer later. + +Every topology transaction is built **unsigned** +(`topology.transactions.generate`), signed by each owner's external signer, and +only then submitted (`topology.transactions.load`) — the participant never holds +an owner's private key, matching real guardian custody (HSM/KMS/offline signer). +`devnet/guardian_key_tool.js` is a small Ed25519 keypair/signing helper (Node's +built-in `crypto` module only, no dependencies) that stands in for that custody +system locally; production wires `GG_OWNER_SIGN_CMD_1/2/3` to the real one +instead (see the script's header for the full environment-variable contract). + +[`devnet/genesis_guardian_governance.sh`](devnet/genesis_guardian_governance.sh) +exercises the resulting party: it prepares a `create CoreState` command +co-signed by an external `operator` party and `guardianGovernance` (the +interactive-submission equivalent of `Test.TestCore:setup`'s +`submit (actAs operator <> actAs guardianGovernance)`), has owners sign the +prepared +transaction hash, and executes. It supports three modes — `happy` (2-of-3, +succeeds), `threshold` (1-of-3, rejected), and `forge` (no guardianGovernance +signature at all, rejected — the anti-forgery property from §10: a compromised +operator cannot fabricate a `CoreState` bearing the real governance party). + +[`devnet/test_guardian_governance.sh`](devnet/test_guardian_governance.sh) runs +all of the above against a fresh `dpm sandbox` and asserts the full table: +namespace/threshold/hosting shape, and all three genesis modes' expected +outcome. + +```bash +canton/devnet/test_guardian_governance.sh +``` + +**Sandbox vs. CN fidelity.** The sandbox has no SVs or decentralized +synchronizer, so "registering with the super-validators" cannot be exercised +locally. That step is theoretical here by construction, not by omission: the +decentralized namespace is guardian-owned, so SVs never approve it — CN +"registration" is just (a) onboarding a participant to the CN global +synchronizer (one-time, SV-sponsored validator onboarding) and (b) submitting +the *same* topology transactions this script builds to that synchronizer instead +of the sandbox's, where they are sequenced and seen by all participants. The +script is synchronizer-target-parameterized (`GG_SYNCHRONIZER_ALIAS`, +`GG_CANTON_CONSOLE_CONFIG`) for exactly that reason, and the local run proves +the namespace/party/threshold mechanics port unchanged. + +**Current limitation.** Party hosting is single-participant only +(`HostingParticipant(participant.id, Confirmation)` for the one console-bound +participant) — the sandbox has only one participant to host on, so +multi-participant Confirmation hosting (the production censorship-resistance +requirement) is not exercised locally. Extending the `Seq[HostingParticipant]` +built in `guardian_governance.canton` to additional participant ids is +mechanical; each additional participant's consent-to-host signature follows the +same `transactions.sign(..., signedBy = Seq())` +pattern already used for the first one. + ### Status: opt-in while the k8s devnet path is validated Unlike Sui, the `canton` component is **not** enabled by `--ci`. The guardian @@ -896,14 +968,19 @@ human decision; see Open Questions. — confirm and pin against Daml SDK 3.5.1 / Canton 3.5.x. 2. **`guardianGovernance` threshold party.** `CoreState` is co-signed by and keyed on a `guardianGovernance` party (§4.1), so guardian-set integrity is anchored to - it rather than the operator. Stand it up as a decentralized-namespace external - party governed by a k-of-n threshold of guardian keys (DSO-style) and confirm: - the genesis co-sign ceremony, the topology-level rotation flow (add/remove - guardian keys without touching `CoreState`), and that consumers are configured - with the correct anchor party id. Note the `CoreState` singleton count ("exactly - one") is still behavioral — confirm the genesis automation creates exactly one. - (Emitter/manager **address** integrity is separately owner-bound and - owner-co-signed, §4.2.) + it rather than the operator. §9's `guardianGovernance` external-party bootstrap + stands it up as a decentralized-namespace external party governed by a 2-of-3 + threshold of guardian keys (DSO-style), and its genesis co-sign ceremony is + confirmed locally (2-of-3 succeeds, 1-of-3 and no-signature are rejected). + Remaining before production: the CN-synchronizer submission itself (only + designed/parameterized here, not exercised — no CN localnet available), the + topology-level rotation flow (add/remove guardian keys without touching + `CoreState` — the decentralized namespace's own threshold governs this once + created, but rotation itself is untested), multi-participant Confirmation + hosting, and confirming consumers are configured with the correct anchor party + id. Note the `CoreState` singleton count ("exactly one") is still behavioral — + confirm the genesis automation creates exactly one. (Emitter/manager + **address** integrity is separately owner-bound and owner-co-signed, §4.2.) 3. **Guardian observer topology.** *Mechanism resolved:* the dedicated read-only `guardianObserver` party is implemented as a template `observer` on `Emitter`/`CoreState` (§4.1, §4.2, §10), and `--cantonReadAsParty` maps to it diff --git a/canton/devnet/genesis_guardian_governance.sh b/canton/devnet/genesis_guardian_governance.sh new file mode 100755 index 0000000000..b1bea2c1cc --- /dev/null +++ b/canton/devnet/genesis_guardian_governance.sh @@ -0,0 +1,211 @@ +#!/usr/bin/env bash +# +# Demonstrate that the guardianGovernance external party, bootstrapped by +# setup_guardian_governance.sh, can actually co-sign a transaction: prepare a +# `create CoreState` command (co-signed by an external `operator` party and +# guardianGovernance, mirroring Test.TestCore:setup's +# `submit (actAs operator <> actAs guardianGovernance)` but via interactive +# submission instead of Daml Script), have owners sign the prepared hash, and +# execute. +# +# This is a THIN orchestration layer over the JSON Ledger API's interactive +# submission endpoints (prepare/execute) -- see +# https://docs.digitalasset.com/build/3.5/tutorials/app-dev/external_signing_submission.html +# for the underlying flow. jq/curl/node are already relied on elsewhere in +# this repo (see scripts/delegated-guardian-set-preset.sh); no new +# dependencies. +# +# Usage: +# genesis_guardian_governance.sh +# +# mode: "happy" | "threshold" | "forge" +# happy Operator (1-of-1 external key) + 2-of-3 guardianGovernance +# signatures. Expected: contract created. +# threshold Operator + only 1-of-3 guardianGovernance signatures. +# Expected: rejected (threshold not met). +# forge Operator signs alone; guardianGovernance provides NO +# signature at all. Expected: rejected (the anti-forgery +# crux property -- the operator cannot fabricate the +# guardian-anchored CoreState). +# +# Required environment: +# GG_ARTIFACTS_FILE Artifacts JSON from setup_guardian_governance.sh. +# GG_OPERATOR_ARTIFACTS_FILE +# Same-shaped artifacts JSON for a single-owner +# (1-of-1) external `operator` party (this script +# does not allocate one; see +# test_guardian_governance.sh for how to produce it +# by re-running guardian_governance.canton with +# GG_PARTY_NAME=Operator, GG_THRESHOLD=1 and a +# single owner key repeated, or a dedicated +# single-key setup). +# GG_CORE_PACKAGE_ID Package id of the uploaded wormhole-core DAR +# (see `dpm inspect-dar`). +# CANTON_HOST / CANTON_LEDGER_API_PORT / CANTON_JSON_API_PORT +# Default localhost / 6865 / 6864 (sandbox +# defaults). +set -euo pipefail + +MODE="${1:?usage: genesis_guardian_governance.sh }" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +HOST="${CANTON_HOST:-localhost}" +JSON_PORT="${CANTON_JSON_API_PORT:-6864}" +BASE_URL="http://${HOST}:${JSON_PORT}" + +: "${GG_ARTIFACTS_FILE:?GG_ARTIFACTS_FILE is required}" +: "${GG_OPERATOR_ARTIFACTS_FILE:?GG_OPERATOR_ARTIFACTS_FILE is required}" +: "${GG_CORE_PACKAGE_ID:?GG_CORE_PACKAGE_ID is required}" + +GG_PARTY="$(jq -r '.partyId' "${GG_ARTIFACTS_FILE}")" +GG_THRESHOLD_VALUE="$(jq -r '.threshold' "${GG_ARTIFACTS_FILE}")" +SYNCHRONIZER_ID="$(jq -r '.synchronizerId' "${GG_ARTIFACTS_FILE}")" +# The JSON Ledger API's `synchronizerId` field on PrepareSubmissionRequest +# wants the LOGICAL synchronizer id ("::"), not +# the PHYSICAL id this repo's artifacts carry ("...::-") -- +# strip the trailing "::-" suffix. +LOGICAL_SYNCHRONIZER_ID="$(echo "${SYNCHRONIZER_ID}" | sed -E 's/(::[^:]+)::[0-9]+-[0-9]+$/\1/')" + +OP_PARTY="$(jq -r '.partyId' "${GG_OPERATOR_ARTIFACTS_FILE}")" +OP_FINGERPRINT="$(jq -r '.owners[0].namespace' "${GG_OPERATOR_ARTIFACTS_FILE}")" +OP_KEYFILE_PUB="$(jq -r '.owners[0].publicKeyFile' "${GG_OPERATOR_ARTIFACTS_FILE}")" +OP_KEYFILE="${OP_KEYFILE_PUB%.pub}.key" + +sign_hash() { + local keyfile="$1" hash_hex="$2" + node "${SCRIPT_DIR}/guardian_key_tool.js" sign "${keyfile}" "${hash_hex}" +} + +hex_to_b64() { + python3 -c 'import sys, base64; sys.stdout.write(base64.b64encode(bytes.fromhex(sys.argv[1])).decode())' "$1" +} + +b64_to_hex() { + python3 -c 'import sys, base64; sys.stdout.write(base64.b64decode(sys.argv[1]).hex())' "$1" +} + +sig_json() { + local sig_hex="$1" fingerprint="$2" + local sig_b64 + sig_b64="$(hex_to_b64 "${sig_hex}")" + jq -n --arg sig "${sig_b64}" --arg fp "${fingerprint}" \ + '{format: "SIGNATURE_FORMAT_CONCAT", signature: $sig, signedBy: $fp, signingAlgorithmSpec: "SIGNING_ALGORITHM_SPEC_ED25519"}' +} + +# --- Build the create-CoreState command, matching mkInitialCoreState's devnet +# values (Test.TestCore.setup / TestCore.daml). --- +COMMAND_ID="guardian-governance-genesis-${MODE}-$$" +CREATE_ARGS="$(jq -n \ + --arg operator "${OP_PARTY}" \ + --arg gg "${GG_PARTY}" \ + '{ + operator: $operator, + guardianGovernance: $gg, + chainId: "72", + governanceChainId: "1", + governanceContract: "0000000000000000000000000000000000000000000000000000000000000004", + guardianSetIndex: "0", + guardianSets: [["0", {"keys": ["befa429d57cd18b7f8a4d91a2da9ab4af05d0fbe"]}]], + messageFee: "0", + consumedGovernance: {"map": []} + }')" + +PREPARE_REQUEST="$(jq -n \ + --arg cmdId "${COMMAND_ID}" \ + --arg op "${OP_PARTY}" \ + --arg gg "${GG_PARTY}" \ + --arg synchronizerId "${LOGICAL_SYNCHRONIZER_ID}" \ + --arg templateId "${GG_CORE_PACKAGE_ID}:Wormhole.Core.State:CoreState" \ + --argjson createArgs "${CREATE_ARGS}" \ + '{ + commandId: $cmdId, + actAs: [$op, $gg], + synchronizerId: $synchronizerId, + packageIdSelectionPreference: [], + verboseHashing: false, + hashingSchemeVersion: "HASHING_SCHEME_VERSION_V3", + userId: "participant_admin", + commands: [ { CreateCommand: { templateId: $templateId, createArguments: $createArgs } } ] + }')" + +echo "[genesis:${MODE}] preparing CoreState create command..." >&2 +PREPARE_RESPONSE="$(curl -sf -X POST "${BASE_URL}/v2/interactive-submission/prepare" \ + -H 'Content-Type: application/json' -d "${PREPARE_REQUEST}")" +PREPARED_TX="$(echo "${PREPARE_RESPONSE}" | jq -r '.preparedTransaction')" +HASH_B64="$(echo "${PREPARE_RESPONSE}" | jq -r '.preparedTransactionHash')" +HSV="$(echo "${PREPARE_RESPONSE}" | jq -r '.hashingSchemeVersion')" +HASH_HEX="$(b64_to_hex "${HASH_B64}")" + +OP_SIG_HEX="$(sign_hash "${OP_KEYFILE}" "${HASH_HEX}")" +OP_SIG_JSON="$(sig_json "${OP_SIG_HEX}" "${OP_FINGERPRINT}")" + +GG_SIGNATURES="[]" +case "${MODE}" in + happy) + # Sign with the first GG_THRESHOLD_VALUE owners -- exactly meets threshold. + for i in $(seq 0 $((GG_THRESHOLD_VALUE - 1))); do + fp="$(jq -r ".owners[$i].namespace" "${GG_ARTIFACTS_FILE}")" + pubfile="$(jq -r ".owners[$i].publicKeyFile" "${GG_ARTIFACTS_FILE}")" + keyfile="${pubfile%.pub}.key" + sig_hex="$(sign_hash "${keyfile}" "${HASH_HEX}")" + GG_SIGNATURES="$(echo "${GG_SIGNATURES}" | jq --argjson s "$(sig_json "${sig_hex}" "${fp}")" '. + [$s]')" + done + ;; + threshold) + # One signature only -- below threshold, must be rejected. + fp="$(jq -r '.owners[0].namespace' "${GG_ARTIFACTS_FILE}")" + pubfile="$(jq -r '.owners[0].publicKeyFile' "${GG_ARTIFACTS_FILE}")" + keyfile="${pubfile%.pub}.key" + sig_hex="$(sign_hash "${keyfile}" "${HASH_HEX}")" + GG_SIGNATURES="[$(sig_json "${sig_hex}" "${fp}")]" + ;; + forge) + # No guardianGovernance signature at all -- operator alone must not be + # able to forge the CoreState. + GG_SIGNATURES="[]" + ;; + *) + echo "unknown mode: ${MODE}" >&2 + exit 2 + ;; +esac + +PARTY_SIGNATURES="$(jq -n \ + --arg op "${OP_PARTY}" --argjson opSig "[${OP_SIG_JSON}]" \ + --arg gg "${GG_PARTY}" --argjson ggSig "${GG_SIGNATURES}" \ + 'if ($ggSig | length) > 0 then + { signatures: [ {party: $op, signatures: $opSig}, {party: $gg, signatures: $ggSig} ] } + else + { signatures: [ {party: $op, signatures: $opSig} ] } + end')" + +EXECUTE_REQUEST="$(jq -n \ + --arg prepared "${PREPARED_TX}" \ + --argjson partySignatures "${PARTY_SIGNATURES}" \ + --arg submissionId "${COMMAND_ID}-exec" \ + --arg hsv "${HSV}" \ + '{ + preparedTransaction: $prepared, + partySignatures: $partySignatures, + submissionId: $submissionId, + userId: "participant_admin", + hashingSchemeVersion: $hsv, + deduplicationPeriod: { Empty: {} } + }')" + +OWNER_COUNT="$(jq '.owners | length' "${GG_ARTIFACTS_FILE}")" +echo "[genesis:${MODE}] executing with $(echo "${GG_SIGNATURES}" | jq 'length')-of-${OWNER_COUNT} guardianGovernance signature(s) (threshold ${GG_THRESHOLD_VALUE})..." >&2 +EXEC_RESPONSE_FILE="$(mktemp)" +trap 'rm -f "${EXEC_RESPONSE_FILE}"' EXIT +HTTP_CODE="$(curl -s -o "${EXEC_RESPONSE_FILE}" -w '%{http_code}' -X POST \ + "${BASE_URL}/v2/interactive-submission/execute" -H 'Content-Type: application/json' -d "${EXECUTE_REQUEST}")" + +echo "[genesis:${MODE}] HTTP ${HTTP_CODE}" >&2 +cat "${EXEC_RESPONSE_FILE}" +echo + +if [ "${HTTP_CODE}" = "200" ]; then + exit 0 +else + exit 1 +fi diff --git a/canton/devnet/guardian_governance.canton b/canton/devnet/guardian_governance.canton new file mode 100644 index 0000000000..17a75ba773 --- /dev/null +++ b/canton/devnet/guardian_governance.canton @@ -0,0 +1,244 @@ +// Canton console topology script: bootstrap `guardianGovernance` as an +// external, decentralized-namespace party owned by a k-of-n threshold of +// guardian keys (default 2-of-3), following the same pattern the Canton +// Network DSO party uses. +// +// Run via `dpm canton-console --bootstrap guardian_governance.canton` against +// a running participant (sandbox locally; a real participant connected to +// the CN global synchronizer in production -- see canton/README.md). The +// bash wrapper `setup_guardian_governance.sh` drives this end to end. +// +// EXTERNAL SIGNING FLOW: every owner-authorized topology transaction is built +// in three explicit steps -- generated UNSIGNED (`topology.transactions.generate`), +// signed by each owner's external signer over the transaction hash +// (GG_OWNER_SIGN_CMD_*, wrapped via `Signature.fromExternalSigning`), and only +// then submitted fully signed (`topology.transactions.load`). The participant +// only ever handles hashes, public keys, and signatures; owner private keys +// stay inside guardian custody (HSM/KMS/offline signer) and never touch the +// participant host. Production uses this exact code path -- local/test and +// production differ only in what GG_OWNER_SIGN_CMD_* points at (this repo's +// `guardian_key_tool.js` vs. a real custody CLI), not in the +// topology-construction code. +// +// Vault-backed signing (the console's one-call convenience methods such as +// `topology.decentralized_namespaces.propose` / `party_to_participant_mappings +// .propose` with `signedBy=`, or `transactions.sign`) draws +// private keys from the participant's own crypto vault, so it is used here +// only for the participant's own identity key -- the consent-to-host +// signature below; using it for owner signatures would require importing +// guardian keys into the node. +// +// PartyToKeyMapping is deliberately NOT used: it was deprecated in Canton 3.4 +// in favor of putting the party's signing keys directly on PartyToParticipant +// via `partySigningKeys` (a `SigningKeysWithThreshold`), which is what this +// script does. +// +// Configuration (environment variables; see setup_guardian_governance.sh for +// the values it sets): +// GG_OWNER_PUBKEY_1/2/3 Paths to the three owners' Ed25519 public keys, +// DER-encoded X.509 SubjectPublicKeyInfo (the same +// format `openssl` and Node's `crypto` module +// produce, and what `guardian_key_tool.js generate` +// writes as `.pub`). Required. +// GG_OWNER_SIGN_CMD_1/2/3 Shell command that signs a hex-encoded hash and +// prints a hex-encoded raw Ed25519 signature to +// stdout, invoked as ` ` (the command +// string may itself contain arguments, e.g. +// "node guardian_key_tool.js sign owner1.key"). +// This is the pluggable custody boundary: locally it +// runs guardian_key_tool.js against a same-host key +// file; in production it should invoke a real +// HSM/KMS/offline signer. Required. +// GG_THRESHOLD Signing/authorization threshold, k of 3. Default 2. +// GG_PARTY_NAME Party name allocated under the decentralized +// namespace. Default "guardianGovernance". +// GG_SYNCHRONIZER_ALIAS Alias of the synchronizer to submit topology to. +// Default: the participant's sole connected +// synchronizer (fails if there is more than one and +// this is unset). +// GG_ARTIFACTS_FILE Output path for the artifacts JSON (party id, +// namespace id, owner fingerprints/pubkey paths, +// threshold). Default "guardian-governance-artifacts.json". +// +// This script assumes exactly one participant is bound in the console +// session (true for `dpm canton-console` against sandbox, and for a +// single-participant production console session) and hosts the party on that +// participant at Confirmation permission. Multi-participant hosting (the +// production censorship-resistance requirement) is a straightforward +// extension of the `HostingParticipant` sequence built below -- add entries +// for the other participants' ids -- and the same externally-signed-load +// pattern applies to their consent-to-host signatures. + +import com.digitalasset.canton.topology._ +import com.digitalasset.canton.topology.transaction._ +import com.digitalasset.canton.config.RequireTypes.PositiveInt +import com.digitalasset.canton.crypto._ +import com.digitalasset.canton.topology.admin.grpc.TopologyStoreId +import com.digitalasset.canton.admin.api.client.commands.TopologyAdminCommands.Write.GenerateTransactions.Proposal +import com.google.protobuf.ByteString +import com.daml.nonempty.NonEmpty +import scala.sys.process._ +import scala.collection.immutable.Set +import java.nio.file.{Files, Paths} + +def env(name: String): String = + sys.env.getOrElse(name, throw new RuntimeException(s"missing required environment variable: $name")) +def envOr(name: String, default: String): String = sys.env.getOrElse(name, default) + +val ownerPubkeyFiles = Seq(env("GG_OWNER_PUBKEY_1"), env("GG_OWNER_PUBKEY_2"), env("GG_OWNER_PUBKEY_3")) +val ownerSignCmds = Seq(env("GG_OWNER_SIGN_CMD_1"), env("GG_OWNER_SIGN_CMD_2"), env("GG_OWNER_SIGN_CMD_3")) +val threshold = PositiveInt.tryCreate(envOr("GG_THRESHOLD", "2").toInt) +val partyName = envOr("GG_PARTY_NAME", "guardianGovernance") +val synchronizerAliasOpt = sys.env.get("GG_SYNCHRONIZER_ALIAS") +val artifactsFile = envOr("GG_ARTIFACTS_FILE", "guardian-governance-artifacts.json") + +def bytesToHex(b: Array[Byte]): String = b.map("%02x".format(_)).mkString +def hexToBytes(s: String): Array[Byte] = s.grouped(2).map(Integer.parseInt(_, 16).toByte).toArray + +val participant = consoleEnvironment.participants.all match { + case Seq(single) => single + case other => throw new RuntimeException( + s"expected exactly one participant bound in this console session, found ${other.size}") +} + +val connected = participant.synchronizers.list_connected() +val targetSynchronizer = synchronizerAliasOpt match { + case Some(alias) => + connected.find(_.synchronizerAlias.unwrap == alias) + .getOrElse(throw new RuntimeException(s"not connected to synchronizer alias '$alias'")) + case None => + connected match { + case Seq(single) => single + case other => throw new RuntimeException( + s"connected to ${other.size} synchronizers; set GG_SYNCHRONIZER_ALIAS to disambiguate") + } +} +val synchronizerId = targetSynchronizer.physicalSynchronizerId +val store: TopologyStoreId = TopologyStoreId.Synchronizer(synchronizerId) +val protocolVersion = com.digitalasset.canton.version.ProtocolVersion.latest + +// --- Load the three owner public keys and give Canton their exact fingerprint/namespace. --- +// `SigningPublicKey.create` is a pure value constructor (no vault access, no +// private-key material involved); its `.fingerprint` matches exactly what +// `keys.public.upload` would compute server-side for the same bytes, so the +// namespace derivation below is correct without needing a round trip. +def loadOwnerKey(pubFile: String): SigningPublicKey = { + val der = ByteString.copyFrom(Files.readAllBytes(Paths.get(pubFile))) + SigningPublicKey + .create(CryptoKeyFormat.DerX509Spki, der, SigningKeySpec.EcCurve25519, SigningKeyUsage.All) + .getOrElse(sys.error(s"invalid owner public key at $pubFile")) +} +val ownerPubkeys = ownerPubkeyFiles.map(loadOwnerKey) +val ownerNamespaces = ownerPubkeys.map(k => Namespace(k.fingerprint)) + +// Invoke this owner's external signer (GG_OWNER_SIGN_CMD_i) over the given +// topology-transaction hash and wrap the raw signature bytes it returns as a +// Canton `Signature`. The signer never runs inside this JVM process and this +// script never sees (or could see) any owner's private key material. +def externalSign(signCmd: String, hash: Hash, fingerprint: Fingerprint): Signature = { + val hex = bytesToHex(hash.getCryptographicEvidence.toByteArray) + val sigHex = (signCmd + " " + hex).!!.trim + val sigBytes = ByteString.copyFrom(hexToBytes(sigHex)) + Signature.fromExternalSigning( + SignatureFormat.fromSigningAlgoSpec(SigningAlgorithmSpec.Ed25519), + sigBytes, + fingerprint, + SigningAlgorithmSpec.Ed25519, + ) +} + +def generateUnsigned[M <: TopologyMapping](mapping: M): TopologyTransaction[TopologyChangeOp, M] = + participant.topology.transactions + .generate(Seq(Proposal(mapping, store))) + .head + .asInstanceOf[TopologyTransaction[TopologyChangeOp, M]] + +println(s"[guardian-governance] owner namespaces: ${ownerNamespaces.mkString(", ")}") + +// --- Step 1: each owner's root NamespaceDelegation, self-signed externally. --- +// A namespace's root key must have an explicit self-signed NamespaceDelegation +// on record before Canton will accept further signatures "as" that namespace +// (this bootstraps the namespace's root of trust, the same way a node's own +// identity key does). +val rootDelegations = (ownerNamespaces, ownerPubkeys, ownerSignCmds).zipped.map { (ns, pub, signCmd) => + val mapping = NamespaceDelegation + .create(ns, pub, DelegationRestriction.CanSignAllMappings) + .getOrElse(sys.error(s"failed to build NamespaceDelegation for $ns")) + val unsigned = generateUnsigned(mapping) + val sig = externalSign(signCmd, unsigned.hash.hash, ns.fingerprint) + SignedTopologyTransaction.withSignature(unsigned, sig, isProposal = false, protocolVersion) +} +participant.topology.transactions.load(rootDelegations, store) +println("[guardian-governance] root namespace delegations loaded") + +// --- Step 2: DecentralizedNamespaceDefinition, threshold-of-3, all 3 owners externally sign. --- +// Creating a brand-new decentralized namespace requires every declared owner +// to authorize it (the namespace does not exist yet, so its own threshold +// cannot yet govern authorization of this transaction); the threshold takes +// effect for everything that references the namespace afterwards -- including +// amending this very definition later (owner rotation). +val ownerNamespaceSet: Set[Namespace] = ownerNamespaces.toSet +val dnNamespace = DecentralizedNamespaceDefinition.computeNamespace(ownerNamespaceSet) +val dn = DecentralizedNamespaceDefinition.tryCreate(dnNamespace, threshold, ownerNamespaceSet) +val unsignedDn = generateUnsigned(dn) +val dnSignatures = (ownerNamespaces, ownerSignCmds).zipped.map { (ns, signCmd) => + externalSign(signCmd, unsignedDn.hash.hash, ns.fingerprint) +} +val signedDn = dnSignatures.tail.foldLeft( + SignedTopologyTransaction.withSignature(unsignedDn, dnSignatures.head, isProposal = false, protocolVersion) +)(_.addSingleSignature(_)) +participant.topology.transactions.load(Seq(signedDn), store) +println(s"[guardian-governance] decentralized namespace created: $dnNamespace (threshold ${threshold.value}-of-${ownerNamespaces.size})") + +// --- Step 3: allocate the party under the decentralized namespace, host it at +// Confirmation, and register the same owner keys as its threshold signing +// keys (PartyToParticipant.partySigningKeys -- the modern replacement for the +// deprecated standalone PartyToKeyMapping). --- +val partyId = PartyId.tryCreate(partyName, dnNamespace) +val signingKeys = SigningKeysWithThreshold(NonEmpty.mk(Set, ownerPubkeys.head, ownerPubkeys.tail: _*), threshold) +val partyToParticipant = PartyToParticipant + .create( + partyId, + PositiveInt.one, // confirmation threshold across hosting participants (single participant locally) + Seq(HostingParticipant(participant.id, ParticipantPermission.Confirmation)), + Some(signingKeys), + ) + .getOrElse(sys.error("failed to build PartyToParticipant")) +val unsignedPtp = generateUnsigned(partyToParticipant) +val ptpOwnerSignatures = (ownerNamespaces, ownerSignCmds).zipped.map { (ns, signCmd) => + externalSign(signCmd, unsignedPtp.hash.hash, ns.fingerprint) +} +val signedPtpOwnersOnly = ptpOwnerSignatures.tail.foldLeft( + SignedTopologyTransaction.withSignature(unsignedPtp, ptpOwnerSignatures.head, isProposal = false, protocolVersion) +)(_.addSingleSignature(_)) +// The hosting participant must also consent to host the party; that is a +// LOCAL signature (this participant's own identity key), added via the +// vault-backed `transactions.sign`, not an external one. +val signedPtp = participant.topology.transactions + .sign(Seq(signedPtpOwnersOnly), store, signedBy = Seq(participant.id.uid.namespace.fingerprint)) + .head +participant.topology.transactions.load(Seq(signedPtp), store) +println(s"[guardian-governance] party allocated and hosted: $partyId") + +// --- Artifacts: the handoff to (a) the local test harness's assertions and +// (b) the eventual CN-synchronizer submission. Deliberately plain hand-built +// JSON (no library dependency) since the shape is flat and fully known here. --- +def jsonString(s: String): String = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"" +val ownersJson = (ownerNamespaces, ownerPubkeyFiles).zipped.map { (ns, pubFile) => + s""" { "namespace": ${jsonString(ns.fingerprint.toProtoPrimitive)}, "publicKeyFile": ${jsonString(pubFile)} }""" +}.mkString(",\n") +val artifactsJson = + s"""{ + | "partyId": ${jsonString(partyId.toProtoPrimitive)}, + | "decentralizedNamespace": ${jsonString(dnNamespace.fingerprint.toProtoPrimitive)}, + | "threshold": ${threshold.value}, + | "owners": [ + |$ownersJson + | ], + | "synchronizerId": ${jsonString(synchronizerId.toProtoPrimitive)}, + | "hostingParticipant": ${jsonString(participant.id.toProtoPrimitive)}, + | "hostingPermission": "Confirmation" + |} + |""".stripMargin +Files.write(Paths.get(artifactsFile), artifactsJson.getBytes("UTF-8")) +println(s"[guardian-governance] artifacts written to $artifactsFile") diff --git a/canton/devnet/guardian_key_tool.js b/canton/devnet/guardian_key_tool.js new file mode 100755 index 0000000000..ea71479eae --- /dev/null +++ b/canton/devnet/guardian_key_tool.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +// +// Minimal Ed25519 keypair / signing helper for guardianGovernance owner keys. +// +// This stands in for real guardian custody (HSM/KMS/offline signer) in local +// testing: `generate` produces a keypair the way a guardian's custody system +// would (an Ed25519 SubjectPublicKeyInfo DER public key, importable into +// Canton via `keys.public.upload`, and a PKCS8 DER private key that never +// leaves this host); `sign` produces a raw 64-byte Ed25519 signature over a +// given hash the way a guardian's signer would when asked to co-sign a +// prepared topology transaction or Ledger API interactive submission. +// +// Production usage: replace the `sign` subcommand with a call into the real +// custody system (HSM CLI, KMS API, offline signing ceremony) that accepts +// the same "hex hash in, hex signature out" contract -- guardian_governance. +// canton and genesis_guardian_governance.sh only depend on that CLI contract. +'use strict'; +const crypto = require('crypto'); +const fs = require('fs'); + +const [, , cmd, ...args] = process.argv; + +function generate(prefix) { + const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519'); + const pubDer = publicKey.export({ type: 'spki', format: 'der' }); + const privDer = privateKey.export({ type: 'pkcs8', format: 'der' }); + fs.writeFileSync(`${prefix}.pub`, pubDer); + fs.writeFileSync(`${prefix}.key`, privDer, { mode: 0o600 }); + process.stdout.write(`${prefix}.pub\n${prefix}.key\n`); +} + +function sign(keyFile, hashHex) { + const privDer = fs.readFileSync(keyFile); + const privateKey = crypto.createPrivateKey({ key: privDer, format: 'der', type: 'pkcs8' }); + const msg = Buffer.from(hashHex, 'hex'); + const sig = crypto.sign(null, msg, privateKey); + process.stdout.write(sig.toString('hex') + '\n'); +} + +if (cmd === 'generate' && args.length === 1) { + generate(args[0]); +} else if (cmd === 'sign' && args.length === 2) { + sign(args[0], args[1]); +} else { + console.error('usage: guardian_key_tool.js generate | sign '); + process.exit(1); +} diff --git a/canton/devnet/setup_guardian_governance.sh b/canton/devnet/setup_guardian_governance.sh new file mode 100755 index 0000000000..0c41e014c5 --- /dev/null +++ b/canton/devnet/setup_guardian_governance.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# +# Bootstrap `guardianGovernance` as an external, decentralized-namespace party +# (default 2-of-3 guardian keys) by running the guardian_governance.canton +# console script against a running participant. Mirrors bootstrap.sh's shape: +# wait for the Ledger API, then run the scripted step. +# +# Two modes, selected by GG_GENERATE_TEST_KEYS: +# 1 (default) Local/test: generate three Ed25519 keypairs on this host with +# guardian_key_tool.js and use them as the three owners. This +# is what test_guardian_governance.sh uses; it is NOT a +# production key-custody model (see below). +# 0 Production-shaped: the caller supplies GG_OWNER_PUBKEY_1/2/3 +# (DER-encoded Ed25519 public keys already exported from +# guardian custody -- HSM/KMS/offline signer) and +# GG_OWNER_SIGN_CMD_1/2/3 (a command per owner that signs a +# hex hash and prints a hex signature, backed by that same +# custody system). No private key material is generated or +# touched by this script in this mode. +set -euo pipefail + +HOST="${CANTON_HOST:-localhost}" +PORT="${CANTON_LEDGER_API_PORT:-6865}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +GENERATE_TEST_KEYS="${GG_GENERATE_TEST_KEYS:-1}" +KEY_DIR="${GG_KEY_DIR:-/tmp/guardian-governance-keys}" +export GG_ARTIFACTS_FILE="${GG_ARTIFACTS_FILE:-/tmp/guardian-governance-artifacts.json}" +export GG_THRESHOLD="${GG_THRESHOLD:-2}" +export GG_PARTY_NAME="${GG_PARTY_NAME:-guardianGovernance}" + +"${SCRIPT_DIR}/wait_for_ledger.sh" "${HOST}" "${PORT}" + +if [ "${GENERATE_TEST_KEYS}" = "1" ]; then + echo "[guardian-governance] GG_GENERATE_TEST_KEYS=1: generating three local test owner keys in ${KEY_DIR}" + echo "[guardian-governance] (production must set GG_GENERATE_TEST_KEYS=0 and supply real custody-backed keys/signers -- see this script's header)" + mkdir -p "${KEY_DIR}" + for i in 1 2 3; do + node "${SCRIPT_DIR}/guardian_key_tool.js" generate "${KEY_DIR}/owner${i}" >/dev/null + done + export GG_OWNER_PUBKEY_1="${KEY_DIR}/owner1.pub" + export GG_OWNER_PUBKEY_2="${KEY_DIR}/owner2.pub" + export GG_OWNER_PUBKEY_3="${KEY_DIR}/owner3.pub" + export GG_OWNER_SIGN_CMD_1="node ${SCRIPT_DIR}/guardian_key_tool.js sign ${KEY_DIR}/owner1.key" + export GG_OWNER_SIGN_CMD_2="node ${SCRIPT_DIR}/guardian_key_tool.js sign ${KEY_DIR}/owner2.key" + export GG_OWNER_SIGN_CMD_3="node ${SCRIPT_DIR}/guardian_key_tool.js sign ${KEY_DIR}/owner3.key" +else + : "${GG_OWNER_PUBKEY_1:?GG_OWNER_PUBKEY_1 is required when GG_GENERATE_TEST_KEYS=0}" + : "${GG_OWNER_PUBKEY_2:?GG_OWNER_PUBKEY_2 is required when GG_GENERATE_TEST_KEYS=0}" + : "${GG_OWNER_PUBKEY_3:?GG_OWNER_PUBKEY_3 is required when GG_GENERATE_TEST_KEYS=0}" + : "${GG_OWNER_SIGN_CMD_1:?GG_OWNER_SIGN_CMD_1 is required when GG_GENERATE_TEST_KEYS=0}" + : "${GG_OWNER_SIGN_CMD_2:?GG_OWNER_SIGN_CMD_2 is required when GG_GENERATE_TEST_KEYS=0}" + : "${GG_OWNER_SIGN_CMD_3:?GG_OWNER_SIGN_CMD_3 is required when GG_GENERATE_TEST_KEYS=0}" +fi + +CONSOLE_CONFIG_ARGS=() +if [ -n "${GG_CANTON_CONSOLE_CONFIG:-}" ]; then + # Production: point the console at a real participant/synchronizer config + # instead of the sandbox default (`dpm canton-console` connects to sandbox + # on localhost unless given an explicit config). + CONSOLE_CONFIG_ARGS=(-c "${GG_CANTON_CONSOLE_CONFIG}") +fi + +echo "[guardian-governance] running guardian_governance.canton" +dpm canton-console --no-tty "${CONSOLE_CONFIG_ARGS[@]}" --bootstrap "${SCRIPT_DIR}/guardian_governance.canton" < /dev/null + +echo "[guardian-governance] artifacts:" +cat "${GG_ARTIFACTS_FILE}" diff --git a/canton/devnet/test_guardian_governance.sh b/canton/devnet/test_guardian_governance.sh new file mode 100755 index 0000000000..b1754834e2 --- /dev/null +++ b/canton/devnet/test_guardian_governance.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# +# Local end-to-end test for the guardianGovernance external-party bootstrap. +# Starts (or reuses) a `dpm sandbox`, runs setup_guardian_governance.sh, then +# asserts: +# +# - DN created: 3 owners, threshold 2. +# - Party allocated: guardianGovernance::, hosted at Confirmation, +# 2-of-3 party signing keys. +# - 2-of-3 co-signs `CoreState` genesis via interactive submission: succeeds. +# - 1-of-3 co-signs: rejected (threshold enforced). +# - 0-of-3 (operator alone): rejected (anti-forgery -- the crux property). +# +# Usage: test_guardian_governance.sh +# Assumes `dpm sandbox` is NOT already running on :6865/:6864 -- this script +# starts one in a scratch directory and tears it down on exit. Set +# GG_TEST_REUSE_SANDBOX=1 to instead reuse an already-running sandbox +# (skips start/stop; useful for interactive debugging). +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" +WORK_DIR="$(mktemp -d /tmp/guardian-governance-test.XXXXXX)" +SANDBOX_PID="" +FAILURES=0 + +log() { echo "[test-guardian-governance] $*"; } +pass() { log "PASS: $*"; } +fail() { log "FAIL: $*"; FAILURES=$((FAILURES + 1)); } + +cleanup() { + if [ -n "${SANDBOX_PID}" ]; then + log "stopping sandbox (pid ${SANDBOX_PID})" + kill "${SANDBOX_PID}" 2>/dev/null || true + wait "${SANDBOX_PID}" 2>/dev/null || true + # `dpm sandbox` forks the actual JVM as a child rather than exec-ing into + # it, so killing $! (the `dpm` process) can leave the java process + # running, reparented to init. Belt and suspenders: also reap any + # sandbox JVM still listening on our target ports. + pkill -f "canton-open-source.*sandbox" 2>/dev/null || true + fi +} +trap cleanup EXIT + +if [ "${GG_TEST_REUSE_SANDBOX:-0}" != "1" ]; then + log "starting dpm sandbox in ${WORK_DIR}" + (cd "${WORK_DIR}" && exec dpm sandbox --no-tty -C canton.participants.sandbox.ledger-api.address=0.0.0.0) \ + > "${WORK_DIR}/sandbox.log" 2>&1 & + SANDBOX_PID=$! +fi + +"${SCRIPT_DIR}/wait_for_ledger.sh" localhost 6865 + +# --- Build and upload the production DAR (CoreState lives there). --- +log "building wormhole-core DAR" +(cd "${REPO_ROOT}/canton" && dpm build --all >/dev/null) +CORE_DAR="${REPO_ROOT}/canton/core/.daml/dist/wormhole-core-0.1.0.dar" + +UPLOAD_SCRIPT="${WORK_DIR}/upload_dar.canton" +cat > "${UPLOAD_SCRIPT}" <&1)" + GG_CORE_PACKAGE_ID="$(echo "${UPLOAD_OUT}" | grep -oE 'PACKAGE_ID=[a-f0-9]+' | cut -d= -f2)" + [ -n "${GG_CORE_PACKAGE_ID}" ] && break + sleep 2 +done +if [ -z "${GG_CORE_PACKAGE_ID}" ]; then + fail "could not upload/vet wormhole-core DAR" + echo "${UPLOAD_OUT}" + exit 1 +fi +log "uploaded wormhole-core DAR, package id ${GG_CORE_PACKAGE_ID}" +export GG_CORE_PACKAGE_ID + +# --- Set up the 2-of-3 guardianGovernance party. --- +export GG_GENERATE_TEST_KEYS=1 +export GG_KEY_DIR="${WORK_DIR}/guardian-keys" +export GG_ARTIFACTS_FILE="${WORK_DIR}/guardian-artifacts.json" +export GG_THRESHOLD=2 +export GG_PARTY_NAME=guardianGovernance +log "running setup_guardian_governance.sh (guardianGovernance, 2-of-3)" +if ! "${SCRIPT_DIR}/setup_guardian_governance.sh" > "${WORK_DIR}/setup_gg.log" 2>&1; then + fail "setup_guardian_governance.sh (guardianGovernance) exited non-zero" + cat "${WORK_DIR}/setup_gg.log" + exit 1 +fi + +# --- Set up a single-key (1-of-1) external Operator party, reusing the same +# topology script (see genesis_guardian_governance.sh's header for why a +# second external party is needed: interactive submission requires every +# actAs party, not just guardianGovernance, to sign externally). --- +export GG_KEY_DIR="${WORK_DIR}/operator-keys" +export GG_ARTIFACTS_FILE="${WORK_DIR}/operator-artifacts.json" +export GG_THRESHOLD=1 +export GG_PARTY_NAME=Operator +mkdir -p "${GG_KEY_DIR}" +node "${SCRIPT_DIR}/guardian_key_tool.js" generate "${GG_KEY_DIR}/owner1" >/dev/null +export GG_GENERATE_TEST_KEYS=0 +export GG_OWNER_PUBKEY_1="${GG_KEY_DIR}/owner1.pub" +export GG_OWNER_PUBKEY_2="${GG_KEY_DIR}/owner1.pub" +export GG_OWNER_PUBKEY_3="${GG_KEY_DIR}/owner1.pub" +export GG_OWNER_SIGN_CMD_1="node ${SCRIPT_DIR}/guardian_key_tool.js sign ${GG_KEY_DIR}/owner1.key" +export GG_OWNER_SIGN_CMD_2="${GG_OWNER_SIGN_CMD_1}" +export GG_OWNER_SIGN_CMD_3="${GG_OWNER_SIGN_CMD_1}" +log "running setup_guardian_governance.sh (Operator, 1-of-1)" +if ! "${SCRIPT_DIR}/setup_guardian_governance.sh" > "${WORK_DIR}/setup_op.log" 2>&1; then + fail "setup_guardian_governance.sh (Operator) exited non-zero" + cat "${WORK_DIR}/setup_op.log" + exit 1 +fi + +GG_ARTIFACTS_FILE="${WORK_DIR}/guardian-artifacts.json" +OP_ARTIFACTS_FILE="${WORK_DIR}/operator-artifacts.json" + +# --- Assertions: topology shape. --- +OWNER_COUNT="$(jq '.owners | length' "${GG_ARTIFACTS_FILE}")" +THRESHOLD_VALUE="$(jq -r '.threshold' "${GG_ARTIFACTS_FILE}")" +HOSTING_PERMISSION="$(jq -r '.hostingPermission' "${GG_ARTIFACTS_FILE}")" +PARTY_ID="$(jq -r '.partyId' "${GG_ARTIFACTS_FILE}")" +DN_ID="$(jq -r '.decentralizedNamespace' "${GG_ARTIFACTS_FILE}")" + +[ "${OWNER_COUNT}" = "3" ] && pass "decentralized namespace has 3 owners" || fail "expected 3 owners, got ${OWNER_COUNT}" +[ "${THRESHOLD_VALUE}" = "2" ] && pass "decentralized namespace threshold is 2" || fail "expected threshold 2, got ${THRESHOLD_VALUE}" +[ "${HOSTING_PERMISSION}" = "Confirmation" ] && pass "party hosted at Confirmation" || fail "expected Confirmation, got ${HOSTING_PERMISSION}" +if [ "${PARTY_ID}" = "guardianGovernance::${DN_ID}" ]; then + pass "party id is guardianGovernance::" +else + fail "party id '${PARTY_ID}' does not equal guardianGovernance::${DN_ID}" +fi + +# --- Assertions: genesis interactive submission. --- +export GG_ARTIFACTS_FILE +export GG_OPERATOR_ARTIFACTS_FILE="${OP_ARTIFACTS_FILE}" + +log "genesis: 2-of-3 (happy path, expect success)" +if "${SCRIPT_DIR}/genesis_guardian_governance.sh" happy > "${WORK_DIR}/genesis_happy.log" 2>&1; then + pass "2-of-3 co-signed CoreState genesis succeeded" +else + fail "2-of-3 co-signed CoreState genesis was rejected" + cat "${WORK_DIR}/genesis_happy.log" +fi + +log "genesis: 1-of-3 (expect rejection)" +if "${SCRIPT_DIR}/genesis_guardian_governance.sh" threshold > "${WORK_DIR}/genesis_threshold.log" 2>&1; then + fail "1-of-3 co-signed CoreState genesis was accepted (threshold not enforced!)" +else + pass "1-of-3 co-signed CoreState genesis was rejected" +fi + +log "genesis: 0-of-3 / operator-only (anti-forgery, expect rejection)" +if "${SCRIPT_DIR}/genesis_guardian_governance.sh" forge > "${WORK_DIR}/genesis_forge.log" 2>&1; then + fail "operator-only CoreState genesis was accepted (forgery not blocked!)" +else + pass "operator-only CoreState genesis was rejected (forgery blocked)" +fi + +echo +if [ "${FAILURES}" -eq 0 ]; then + log "ALL CHECKS PASSED" + exit 0 +else + log "${FAILURES} CHECK(S) FAILED" + exit 1 +fi diff --git a/cspell-custom-words.txt b/cspell-custom-words.txt index 46f4e2791a..eac927ce3f 100644 --- a/cspell-custom-words.txt +++ b/cspell-custom-words.txt @@ -108,6 +108,7 @@ journalctl karura Karura Keccak +keypair kevm KEVM keymap