From c9fb8dbf87b632ca3902bfee2ec03085cd3f18d5 Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Mon, 6 Jul 2026 16:34:45 -0700 Subject: [PATCH 1/7] canton: bootstrap guardianGovernance as an external decentralized-namespace party Adds the console topology script that creates guardianGovernance as a 2-of-3 decentralized-namespace external party (DSO-style): three owner namespaces, a DecentralizedNamespaceDefinition with a 2-of-3 threshold, and the party hosted at Confirmation with the same threshold on its PartyToParticipant.partySigningKeys (PartyToKeyMapping is deprecated as of Canton 3.4). Every topology transaction is built unsigned and signed externally (Signature.fromExternalSigning) rather than via the participant's own vault, so an owner's private key never has to touch a participant host. guardian_key_tool.js is a small Ed25519 keygen/signing helper (Node's built-in crypto only, no dependencies) standing in for real guardian custody (HSM/KMS/offline signer) locally; production only needs to repoint GG_OWNER_SIGN_CMD_* at the real signer. --- canton/devnet/guardian_governance.canton | 239 +++++++++++++++++++++++ canton/devnet/guardian_key_tool.js | 52 +++++ 2 files changed, 291 insertions(+) create mode 100644 canton/devnet/guardian_governance.canton create mode 100755 canton/devnet/guardian_key_tool.js diff --git a/canton/devnet/guardian_governance.canton b/canton/devnet/guardian_governance.canton new file mode 100644 index 0000000000..ff1bc14269 --- /dev/null +++ b/canton/devnet/guardian_governance.canton @@ -0,0 +1,239 @@ +// 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. +// +// WHY THIS DOES NOT USE `topology.decentralized_namespaces.propose` / +// `party_to_participant_mappings.propose` DIRECTLY WITH `signedBy=`: +// those console convenience methods sign locally, using a private key that +// must live in THIS participant's own crypto vault -- fine for the +// participant's own identity, wrong for guardian owner keys, which must never +// touch a participant host (see GG_OWNER_SIGN_CMD_* below). Instead this +// script builds each topology transaction UNSIGNED +// (`topology.transactions.generate`), asks each owner's external signer for a +// raw signature over its hash, wraps that with the public +// `Signature.fromExternalSigning` constructor, and only then submits the +// fully-signed transaction (`topology.transactions.load`). This is the same +// shape production custody uses -- local/test and production differ only in +// what GG_OWNER_SIGN_CMD_* points at (this repo's `guardian_key_tool.js` vs. +// a real HSM/KMS CLI), not in the topology-construction code. +// +// 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..66abd6cb18 --- /dev/null +++ b/canton/devnet/guardian_key_tool.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +// +// Minimal Ed25519 keypair / signing helper for guardianGovernance owner keys. +// Uses ONLY Node's built-in `crypto` module (Ed25519 has been supported +// natively since Node 12) -- no third-party dependencies, so no supply-chain +// surface to audit. +// +// 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, +// not on this file, so swapping the signer does not require touching either +// script (see GG_OWNER_SIGN_CMD_* in setup_guardian_governance.sh). +'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); +} From 2ace8624c1c001097c0a19760f0c4c893663a40e Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Mon, 6 Jul 2026 16:34:53 -0700 Subject: [PATCH 2/7] canton: bash wrapper and genesis-signing demo for guardianGovernance setup_guardian_governance.sh mirrors bootstrap.sh's shape (wait for the Ledger API, then run the scripted step): it self-generates three local test owner keys by default (GG_GENERATE_TEST_KEYS=1), or accepts externally-supplied public keys and signers for production (GG_GENERATE_TEST_KEYS=0 + GG_OWNER_PUBKEY_*/GG_OWNER_SIGN_CMD_*), and runs guardian_governance.canton. genesis_guardian_governance.sh exercises the resulting party over the JSON Ledger API's interactive-submission endpoints: prepares a `create CoreState` command co-signed by an external operator party and guardianGovernance, has owners sign the prepared hash, and executes. Three modes cover the anti-forgery property: 2-of-3 succeeds, 1-of-3 is rejected (threshold), and no guardianGovernance signature at all is rejected (a compromised operator cannot forge the anchor). --- canton/devnet/genesis_guardian_governance.sh | 211 +++++++++++++++++++ canton/devnet/setup_guardian_governance.sh | 68 ++++++ 2 files changed, 279 insertions(+) create mode 100755 canton/devnet/genesis_guardian_governance.sh create mode 100755 canton/devnet/setup_guardian_governance.sh 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/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}" From 86f751ec6297ae35efa4690c9f2d237ee63ba13d Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Mon, 6 Jul 2026 16:35:01 -0700 Subject: [PATCH 3/7] canton: local test harness for the guardianGovernance bootstrap test_guardian_governance.sh runs the full local test strategy against a scratch dpm sandbox: builds and uploads the real wormhole-core DAR, bootstraps guardianGovernance (2-of-3) and a single-key external Operator, then asserts the decentralized namespace/threshold/hosting shape and all three genesis-signing outcomes (2-of-3 succeeds, 1-of-3 and no-signature are rejected). Verified green end to end. --- canton/devnet/test_guardian_governance.sh | 171 ++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100755 canton/devnet/test_guardian_governance.sh diff --git a/canton/devnet/test_guardian_governance.sh b/canton/devnet/test_guardian_governance.sh new file mode 100755 index 0000000000..4acff990f2 --- /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 the §4 test table from the design plan: +# +# - 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 From a84834861f663b4864a20571dd503b7b3e5b1801 Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Mon, 6 Jul 2026 16:35:08 -0700 Subject: [PATCH 4/7] canton: document the guardianGovernance external-party bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a canton/README.md §9 subsection covering the new devnet scripts (what each one does, the sandbox-vs-CN fidelity gap, and the single-participant-hosting limitation) and updates Open Question 2 to reflect what's now built (2-of-3 genesis co-signing, confirmed locally) versus what remains (CN-synchronizer submission, owner rotation, multi-participant hosting). --- canton/README.md | 93 +++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 8 deletions(-) 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 From 174d51428777cadd21f6ad984b2c07203484b2c3 Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Tue, 7 Jul 2026 08:59:24 -0700 Subject: [PATCH 5/7] canton: add cspell word 'keypair'; drop stray design-plan reference CI (cspell) flagged 'keypair' as unknown in README.md:745. Also commits an already-pending local fix: test_guardian_governance.sh's header referenced an external design-plan doc readers don't have access to -- the bullet list right there already documents what it asserts, so the doc reference was just noise. --- canton/devnet/test_guardian_governance.sh | 2 +- cspell-custom-words.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/canton/devnet/test_guardian_governance.sh b/canton/devnet/test_guardian_governance.sh index 4acff990f2..b1754834e2 100755 --- a/canton/devnet/test_guardian_governance.sh +++ b/canton/devnet/test_guardian_governance.sh @@ -2,7 +2,7 @@ # # Local end-to-end test for the guardianGovernance external-party bootstrap. # Starts (or reuses) a `dpm sandbox`, runs setup_guardian_governance.sh, then -# asserts the §4 test table from the design plan: +# asserts: # # - DN created: 3 owners, threshold 2. # - Party allocated: guardianGovernance::, hosted at Confirmation, 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 From df33a6a6f012fe37382942b366a81d5745fcb36f Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Tue, 7 Jul 2026 10:37:35 -0700 Subject: [PATCH 6/7] canton: rephrase external-signing comment to lead with the mechanism --- canton/devnet/guardian_governance.canton | 33 ++++++++++++++---------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/canton/devnet/guardian_governance.canton b/canton/devnet/guardian_governance.canton index ff1bc14269..17a75ba773 100644 --- a/canton/devnet/guardian_governance.canton +++ b/canton/devnet/guardian_governance.canton @@ -8,20 +8,25 @@ // the CN global synchronizer in production -- see canton/README.md). The // bash wrapper `setup_guardian_governance.sh` drives this end to end. // -// WHY THIS DOES NOT USE `topology.decentralized_namespaces.propose` / -// `party_to_participant_mappings.propose` DIRECTLY WITH `signedBy=`: -// those console convenience methods sign locally, using a private key that -// must live in THIS participant's own crypto vault -- fine for the -// participant's own identity, wrong for guardian owner keys, which must never -// touch a participant host (see GG_OWNER_SIGN_CMD_* below). Instead this -// script builds each topology transaction UNSIGNED -// (`topology.transactions.generate`), asks each owner's external signer for a -// raw signature over its hash, wraps that with the public -// `Signature.fromExternalSigning` constructor, and only then submits the -// fully-signed transaction (`topology.transactions.load`). This is the same -// shape production custody uses -- local/test and production differ only in -// what GG_OWNER_SIGN_CMD_* points at (this repo's `guardian_key_tool.js` vs. -// a real HSM/KMS CLI), not in the topology-construction code. +// 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 From 5be0795fa05caf64b7e7d56a4a3fd5237b2767a0 Mon Sep 17 00:00:00 2001 From: Bengt Lofgren Date: Tue, 7 Jul 2026 10:48:42 -0700 Subject: [PATCH 7/7] canton: trim guardian_key_tool.js header comment --- canton/devnet/guardian_key_tool.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/canton/devnet/guardian_key_tool.js b/canton/devnet/guardian_key_tool.js index 66abd6cb18..ea71479eae 100755 --- a/canton/devnet/guardian_key_tool.js +++ b/canton/devnet/guardian_key_tool.js @@ -1,9 +1,6 @@ #!/usr/bin/env node // // Minimal Ed25519 keypair / signing helper for guardianGovernance owner keys. -// Uses ONLY Node's built-in `crypto` module (Ed25519 has been supported -// natively since Node 12) -- no third-party dependencies, so no supply-chain -// surface to audit. // // 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 @@ -16,9 +13,7 @@ // 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, -// not on this file, so swapping the signer does not require touching either -// script (see GG_OWNER_SIGN_CMD_* in setup_guardian_governance.sh). +// canton and genesis_guardian_governance.sh only depend on that CLI contract. 'use strict'; const crypto = require('crypto'); const fs = require('fs');