diff --git a/.github/workflows/build-xmtpd.yml b/.github/workflows/build-xmtpd.yml index acaf1b1c..9dc03903 100644 --- a/.github/workflows/build-xmtpd.yml +++ b/.github/workflows/build-xmtpd.yml @@ -30,7 +30,9 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - + - name: Git describe + id: ghd + uses: proudust/gh-describe@v2 - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 @@ -62,7 +64,7 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - build-args: "GIT_COMMIT=dev-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}" + build-args: "VERSION=${{ steps.ghd.outputs.describe }}" - name: Set xmtpd digest output if: ${{ matrix.image == 'xmtpd' }} diff --git a/.github/workflows/release-from-tag.yml b/.github/workflows/release-from-tag.yml index 2a7be429..bdc082fe 100644 --- a/.github/workflows/release-from-tag.yml +++ b/.github/workflows/release-from-tag.yml @@ -55,4 +55,4 @@ jobs: push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - build-args: "GIT_COMMIT=${{ github.ref_name }}-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}" \ No newline at end of file + build-args: "VERSION=${{ github.ref_name }}-${{ github.event_name != 'pull_request' && github.sha || github.event.pull_request.head.sha }}" \ No newline at end of file diff --git a/.github/workflows/solidity.yml b/.github/workflows/solidity.yml index d47dbcb8..5fbd4524 100644 --- a/.github/workflows/solidity.yml +++ b/.github/workflows/solidity.yml @@ -121,3 +121,45 @@ jobs: uses: github/codeql-action/upload-sarif@v3 with: sarif_file: contracts/output.sarif + + abis: + needs: init + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Restore cache + uses: actions/cache/restore@v4 + with: + path: contracts + key: ci-solidity-${{ github.ref }} + + - name: Restore forge + uses: actions/download-artifact@v4 + with: + name: forge + path: /usr/local/bin + + - run: chmod +x /usr/local/bin/forge + + - name: Setup Go + uses: actions/setup-go@v5 + + - name: Install abigen + run: go install github.com/ethereum/go-ethereum/cmd/abigen@v1.14.12 + + - name: Generate ABIs + run: dev/generate + + - name: Check for ABI changes + working-directory: ${{ github.workspace }} + run: | + if git diff --exit-code --ignore-space-change --ignore-all-space --ignore-cr-at-eol -- contracts/pkg; then + echo "No ABI changes detected." + else + echo "ERROR: Generated files are not up to date. Please run 'contracts/dev/generate' and commit the changes." + exit 1 + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 352c3d39..f3b443ff 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,9 +17,11 @@ jobs: name: Test (Node) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive + fetch-tags: true + fetch-depth: 0 - uses: actions/setup-go@v3 with: go-version-file: go.mod diff --git a/.gitignore b/.gitignore index 84d08386..56b642d6 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,8 @@ build/* .idea/**/aws.xml # Generated files -.idea/**/contentModel.xml \ No newline at end of file +.idea/**/contentModel.xml + +# Ignores development deployments +contracts/config/anvil_localnet/* +*.tmp.log \ No newline at end of file diff --git a/cmd/replication/main.go b/cmd/replication/main.go index d23cac49..bb3c4199 100644 --- a/cmd/replication/main.go +++ b/cmd/replication/main.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "github.com/Masterminds/semver/v3" "log" "sync" @@ -18,7 +19,7 @@ import ( "go.uber.org/zap" ) -var Commit string = "unknown" +var Version string = "unknown" var options config.ServerOptions @@ -33,7 +34,7 @@ func main() { } if options.Version { - fmt.Printf("Version: %s\n", Commit) + fmt.Printf("Version: %s\n", Version) return } @@ -48,10 +49,16 @@ func main() { } logger = logger.Named("replication") - logger.Info(fmt.Sprintf("Version: %s", Commit)) + logger.Info(fmt.Sprintf("Version: %s", Version)) + + version, err := semver.NewVersion(Version) + if err != nil { + logger.Error(fmt.Sprintf("Could not parse semver version (%s): %s", Version, err)) + } + if options.Tracing.Enable { logger.Info("starting tracer") - tracing.Start(Commit, logger) + tracing.Start(Version, logger) defer func() { logger.Info("stopping tracer") tracing.Stop() @@ -124,6 +131,7 @@ func main() { dbInstance, blockchainPublisher, fmt.Sprintf("0.0.0.0:%d", options.API.Port), + version, ) if err != nil { log.Fatal("initializing server", zap.Error(err)) diff --git a/contracts/.gitignore b/contracts/.gitignore index 704b55a4..d754609f 100644 --- a/contracts/.gitignore +++ b/contracts/.gitignore @@ -9,6 +9,10 @@ out/ /broadcast/*/31337/ /broadcast/**/dry-run/ +# Ignores development deployments +contracts/config/anvil_localnet/* +*.tmp.log + # Docs docs/ diff --git a/contracts/README.md b/contracts/README.md index 6594d2ce..60407b52 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -108,5 +108,5 @@ The scripts output the deployment and upgrade in the `output` folder. - Deploy with `forge create`: ```shell -forge create --broadcast --legacy --json --rpc-url $DOCKER_RPC_URL --private-key $PRIVATE_KEY "src/Nodes.sol:Nodes" +forge create --broadcast --legacy --json --rpc-url $RPC_URL --private-key $PRIVATE_KEY "src/Nodes.sol:Nodes" ``` diff --git a/contracts/dev/deploy-local b/contracts/dev/deploy-local index b6d0c656..0cee8ad0 100755 --- a/contracts/dev/deploy-local +++ b/contracts/dev/deploy-local @@ -1,25 +1,32 @@ #!/bin/bash -# Deploy the smart contracts to the local anvil node set -euo pipefail -# Make sure the build directory exists -mkdir -p ./build +############################################ +# Work always from the contracts directory # +############################################ +export source_dir="${SOURCE_DIR:-src}" +export build_dir="${BUILD_DIR:-build}" +export output_dir="${OUTPUT_DIR:-pkg}" +export localnet_dir="${LOCALNET_DIR:-config/anvil_localnet}" -# Always work from the contracts directory script_dir=$(dirname "$(realpath "$0")") repo_root=$(realpath "${script_dir}/../") cd "${repo_root}" +mkdir -p "${build_dir}" \ + "${output_dir}" \ + "${localnet_dir}" + source dev/lib/env source dev/lib/common -# Update depencencies -forge soldeer update &> /dev/null -if [ $? -ne 0 ]; then - echo "ERROR: Failed to update dependencies" - exit 1 -fi - +############################################ +# Deploy the smart contracts to ${RPC_URL} # +############################################ +forge_clean +forge_soldeer_update +forge_build_contracts +forge_test_contracts forge_deploy_script group_messages forge_deploy_script identity_updates forge_deploy_script nodes src/Nodes.sol Nodes diff --git a/contracts/dev/generate b/contracts/dev/generate index e97b3677..b3008f5c 100755 --- a/contracts/dev/generate +++ b/contracts/dev/generate @@ -1,18 +1,19 @@ #!/bin/bash - set -euo pipefail -# Default directories (can be overridden with environment variables) -source_dir="${SOURCE_DIR:-src}" -build_dir="${BUILD_DIR:-build}" -output_dir="${OUTPUT_DIR:-pkg}" +############################################ +# Work always from the contracts directory # +############################################ +export source_dir="${SOURCE_DIR:-src}" +export build_dir="${BUILD_DIR:-build}" +export output_dir="${OUTPUT_DIR:-pkg}" -# Ensure required directories exist and clean up old artifacts -function setup_directories() { - mkdir -p "${build_dir}" "${output_dir}" -} +script_dir=$(dirname "$(realpath "$0")") +repo_root=$(realpath "${script_dir}/../") +cd "${repo_root}" + +mkdir -p "${build_dir}" "${output_dir}" -# Generate bindings for a given contract function generate_bindings() { local filename="$1" local package="$(echo "${filename}" | tr '[:upper:]' '[:lower:]')" @@ -47,13 +48,6 @@ function generate_bindings() { } function main() { - # Always work from the contracts directory - script_dir=$(dirname "$(realpath "$0")") - repo_root=$(realpath "${script_dir}/../") - cd "${repo_root}" - - setup_directories - # Define contracts (pass as arguments or use a default list) local contracts=("$@") if [ "${#contracts[@]}" -eq 0 ]; then @@ -62,9 +56,18 @@ function main() { # Generate bindings for each contract for contract in "${contracts[@]}"; do - echo "Processing contract: ${contract}" + echo "⧖ Generating ABIs for contract: ${contract}" generate_bindings "${contract}" done + echo -e "\033[32m✔\033[0m ABIs generated successfully!\n" } +################################################# +# Generate the smart contracts bindings for Go # +################################################# +source dev/lib/env +source dev/lib/common + +forge_clean +forge_soldeer_update main "$@" diff --git a/contracts/dev/lib/common b/contracts/dev/lib/common index f5585254..ca5d44bc 100644 --- a/contracts/dev/lib/common +++ b/contracts/dev/lib/common @@ -1,43 +1,103 @@ #!/bin/bash set -euo pipefail +function get_chain_id() { + hex_chain_id=$(curl -s -X POST -H "Content-Type: application/json" --data '{"jsonrpc":"2.0","method":"eth_chainId","id":1}' ${RPC_URL} | jq -r '.result') + export chain_id=$((hex_chain_id)) +} + function forge_deploy_script() { + get_chain_id case $1 in group_messages) - forge script --rpc-url "${DOCKER_RPC_URL}" --broadcast script/DeployGroupMessages.s.sol &> /dev/null + echo "⧖ Deploying GroupMessages to chainId ${chain_id} using RPC ${RPC_URL}" + forge script --quiet --rpc-url "${RPC_URL}" --broadcast script/DeployGroupMessages.s.sol if [ $? -ne 0 ]; then echo "Failed to deploy group messages contract" exit 1 fi - echo -e "✅ GroupMessages contract deployed.\n" - cat config/anvil_localnet/GroupMessages.json - echo -e "\n" + echo -e "\033[32m✔\033[0m GroupMessages deployed. Deployment details in contracts/config/anvil_localnet/GroupMessages.json\n" ;; identity_updates) - forge script --rpc-url "${DOCKER_RPC_URL}" --broadcast script/DeployIdentityUpdates.s.sol &> /dev/null + echo "⧖ Deploying IdentityUpdates to chainId ${chain_id} using RPC ${RPC_URL}" + forge script --quiet --rpc-url "${RPC_URL}" --broadcast script/DeployIdentityUpdates.s.sol if [ $? -ne 0 ]; then echo "Failed to deploy identity updates contract" exit 1 fi - echo -e "✅ IdentityUpdates contract deployed.\n" - cat config/anvil_localnet/IdentityUpdates.json - echo -e "\n" + echo -e "\033[32m✔\033[0m IdentityUpdates deployed. Deployment details in contracts/config/anvil_localnet/IdentityUpdates.json\n" ;; nodes) # TODO: Migrate to forge script - forge create --broadcast --legacy --json --rpc-url $DOCKER_RPC_URL --private-key $PRIVATE_KEY "$2:$3" > ../build/$3.json - echo -e "✅ Nodes contract deployed.\n" - cat ../build/$3.json - echo -e "\n" + echo "⧖ Deploying Nodes to chainId ${chain_id} using RPC ${RPC_URL}" + forge create --broadcast --legacy --json --rpc-url $RPC_URL --private-key $PRIVATE_KEY "$2:$3" > config/anvil_localnet/$3.json + echo -e "\033[32m✔\033[0m Nodes deployed. Deployment details in contracts/config/anvil_localnet/$3.json\n" ;; *) - echo "Invalid option. Use 'group_messages' or 'identity_updates'." + echo "Invalid option. Use 'group_messages', 'identity_updates' or 'nodes'." exit 1 ;; esac } + +function forge_clean() { + echo -e "⧖ Cleaning old artifacts" + + forge clean &> .forge_clean.tmp.log + if [ $? -ne 0 ]; then + echo "ERROR: Failed to clean old artifacts" + cat .forge_clean.tmp.log + exit 1 + fi + rm .forge_clean.tmp.log + + echo -e "\033[32m✔\033[0m Old artifacts cleaned successfully\n" +} + +function forge_soldeer_update() { + echo -e "⧖ Updating dependencies" + + forge soldeer update &> .forge_soldeer_update.tmp.log + if [ $? -ne 0 ]; then + echo "ERROR: Failed to update dependencies" + cat .forge_soldeer_update.tmp.log + exit 1 + fi + rm .forge_soldeer_update.tmp.log + + echo -e "\033[32m✔\033[0m Dependencies updated successfully\n" +} + +function forge_build_contracts() { + echo -e "⧖ Building contracts" + + forge build &> .forge_build.tmp.log + if [ $? -ne 0 ]; then + echo "ERROR: Failed to build contracts" + cat .forge_build.tmp.log + exit 1 + fi + rm .forge_build.tmp.log + + echo -e "\033[32m✔\033[0m Contracts built successfully\n" +} + +function forge_test_contracts() { + echo -e "⧖ Running contract tests" + + forge test &> .forge_test.tmp.log + if [ $? -ne 0 ]; then + echo "ERROR: Tests failed" + cat .forge_test.tmp.log + exit 1 + fi + rm .forge_test.tmp.log + + echo -e "\033[32m✔\033[0m Tests passed successfully\n" +} + diff --git a/contracts/dev/lib/env b/contracts/dev/lib/env index 226c91d8..bee1d2a5 100644 --- a/contracts/dev/lib/env +++ b/contracts/dev/lib/env @@ -1,6 +1,7 @@ # This is the first default private key for anvil. Nothing sensitive here. export PRIVATE_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 -export DOCKER_RPC_URL=http://localhost:7545 +export RPC_URL=http://localhost:7545 +export VERIFIER_URL= ### XMTP deployment configuration ### # This is the address derivated from the private key above. Not sensitive. diff --git a/dev/baked/Dockerfile b/dev/baked/Dockerfile index b6e8a579..54915cf2 100644 --- a/dev/baked/Dockerfile +++ b/dev/baked/Dockerfile @@ -28,7 +28,7 @@ RUN dev/docker/anvil-background && \ pkill -f anvil && \ sleep 5 -RUN echo "export XMTPD_CONTRACTS_NODES_ADDRESS="$(jq -r '.deployedTo' build/Nodes.json)"" >> contracts.env && \ +RUN echo "export XMTPD_CONTRACTS_NODES_ADDRESS="$(jq -r '.deployedTo' contracts/config/anvil_localnet/Nodes.json)"" >> contracts.env && \ echo "export XMTPD_CONTRACTS_MESSAGES_ADDRESS="$(jq -r '.addresses.groupMessagesProxy' contracts/config/anvil_localnet/GroupMessages.json)"" >> contracts.env && \ echo "export XMTPD_CONTRACTS_IDENTITY_UPDATES_ADDRESS="$(jq -r '.addresses.identityUpdatesProxy' contracts/config/anvil_localnet/IdentityUpdates.json)"" >> contracts.env diff --git a/dev/cli b/dev/cli index eb766f44..75749ce4 100755 --- a/dev/cli +++ b/dev/cli @@ -11,4 +11,4 @@ cd "$TOP_LEVEL_DIR" export XMTPD_LOG_ENCODING=json -go run -ldflags="-X main.Commit=$(git rev-parse HEAD)" cmd/cli/main.go "$@" \ No newline at end of file +go run -ldflags="-X main.Version=$(git describe HEAD --tags --long)" cmd/cli/main.go "$@" \ No newline at end of file diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile index c20fb6da..1011532c 100644 --- a/dev/docker/Dockerfile +++ b/dev/docker/Dockerfile @@ -9,8 +9,8 @@ WORKDIR /app COPY . . # Build the final node binary -ARG GIT_COMMIT=unknown -RUN go build -ldflags="-X 'main.Commit=$GIT_COMMIT'" -o bin/xmtpd cmd/replication/main.go +ARG VERSION=unknown +RUN go build -ldflags="-X 'main.Version=$VERSION'" -o bin/xmtpd cmd/replication/main.go # ACTUAL IMAGE ------------------------------------------------------- diff --git a/dev/docker/Dockerfile-cli b/dev/docker/Dockerfile-cli index 206e06d2..53ccd3f5 100644 --- a/dev/docker/Dockerfile-cli +++ b/dev/docker/Dockerfile-cli @@ -9,8 +9,8 @@ WORKDIR /app COPY . . # Build the final node binary -ARG GIT_COMMIT=unknown -RUN go build -ldflags="-X 'main.Commit=$GIT_COMMIT'" -o bin/xmtpd-cli cmd/cli/main.go +ARG VERSION=unknown +RUN go build -ldflags="-X 'main.Version=$VERSION'" -o bin/xmtpd-cli cmd/cli/main.go # ACTUAL IMAGE ------------------------------------------------------- diff --git a/dev/docker/build b/dev/docker/build index 57d23bd7..bfd3cd31 100755 --- a/dev/docker/build +++ b/dev/docker/build @@ -3,10 +3,10 @@ set -e DOCKER_IMAGE_TAG="${DOCKER_IMAGE_TAG:-dev}" DOCKER_IMAGE_NAME="${DOCKER_IMAGE_NAME:-xmtp/xmtpd}" -GIT_COMMIT="$(git rev-parse HEAD)" +VERSION="$(git describe HEAD --tags --long)" docker buildx build \ - --build-arg="GIT_COMMIT=${GIT_COMMIT}" \ + --build-arg="VERSION=${VERSION}" \ --tag "${DOCKER_IMAGE_NAME}:${DOCKER_IMAGE_TAG}" \ -f dev/docker/Dockerfile \ . diff --git a/dev/local.env b/dev/local.env index 38521690..6b688a9d 100755 --- a/dev/local.env +++ b/dev/local.env @@ -9,12 +9,12 @@ ANVIL_SCRIPTS_OUTPUT=contracts/config/anvil_localnet export XMTPD_DB_WRITER_CONNECTION_STRING="postgres://postgres:xmtp@localhost:8765/postgres?sslmode=disable" # Contract Options -export XMTPD_CONTRACTS_RPC_URL=$DOCKER_RPC_URL # From contracts/.env -XMTPD_CONTRACTS_NODES_ADDRESS="$(jq -r '.deployedTo' build/Nodes.json)" # Built by contracts/deploy-local - TODO: move deployment to forge script +export XMTPD_CONTRACTS_RPC_URL=$RPC_URL # From contracts/dev/lib/env +XMTPD_CONTRACTS_NODES_ADDRESS="$(jq -r '.deployedTo' ${ANVIL_SCRIPTS_OUTPUT}/Nodes.json)" # Built by contracts/dev/deploy-local - TODO: move deployment to forge script export XMTPD_CONTRACTS_NODES_ADDRESS -XMTPD_CONTRACTS_MESSAGES_ADDRESS="$(jq -r '.addresses.groupMessagesProxy' ${ANVIL_SCRIPTS_OUTPUT}/GroupMessages.json)" # Built by contracts/deploy-local +XMTPD_CONTRACTS_MESSAGES_ADDRESS="$(jq -r '.addresses.groupMessagesProxy' ${ANVIL_SCRIPTS_OUTPUT}/GroupMessages.json)" # Built by contracts/dev/deploy-local export XMTPD_CONTRACTS_MESSAGES_ADDRESS -XMTPD_CONTRACTS_IDENTITY_UPDATES_ADDRESS="$(jq -r '.addresses.identityUpdatesProxy' ${ANVIL_SCRIPTS_OUTPUT}/IdentityUpdates.json)" # Built by contracts/deploy-local +XMTPD_CONTRACTS_IDENTITY_UPDATES_ADDRESS="$(jq -r '.addresses.identityUpdatesProxy' ${ANVIL_SCRIPTS_OUTPUT}/IdentityUpdates.json)" # Built by contracts/dev/deploy-local export XMTPD_CONTRACTS_IDENTITY_UPDATES_ADDRESS export ANVIL_ACC_1_PRIVATE_KEY="0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" diff --git a/dev/run b/dev/run index fd3ded4c..807f39e2 100755 --- a/dev/run +++ b/dev/run @@ -8,4 +8,4 @@ export XMTPD_PAYER_ENABLE=true export XMTPD_REPLICATION_ENABLE=true export XMTPD_SYNC_ENABLE=true -go run -ldflags="-X main.Commit=dev-$(git rev-parse HEAD)" cmd/replication/main.go "$@" \ No newline at end of file +go run -ldflags="-X main.Version=$(git describe HEAD --tags --long)" cmd/replication/main.go "$@" \ No newline at end of file diff --git a/dev/run-2 b/dev/run-2 index 2c6a0ed2..d8406d83 100755 --- a/dev/run-2 +++ b/dev/run-2 @@ -12,4 +12,4 @@ export XMTPD_PAYER_ENABLE=true export XMTPD_REPLICATION_ENABLE=true export XMTPD_SYNC_ENABLE=true -go run -ldflags="-X main.Commit=dev-$(git rev-parse HEAD)" cmd/replication/main.go -p 5051 "$@" \ No newline at end of file +go run -ldflags="-X main.Version=$(git describe HEAD --tags --long)" cmd/replication/main.go -p 5051 "$@" \ No newline at end of file diff --git a/dev/up b/dev/up index 5edd05c5..55c46042 100755 --- a/dev/up +++ b/dev/up @@ -1,9 +1,6 @@ #!/bin/bash set -e -go mod tidy -git submodule update --init --recursive - if ! which forge &>/dev/null; then echo "ERROR: Missing foundry binaries. Run 'curl -L https://foundry.paradigm.xyz | bash' and follow the instructions" && exit 1; fi if ! which migrate &>/dev/null; then go install github.com/golang-migrate/migrate/v4/cmd/migrate; fi if ! which golangci-lint &>/dev/null; then curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s v1.56.0; fi @@ -25,14 +22,20 @@ if [[ ${abigen_version} != "1.14.12-stable" ]]; then exit 1 fi +echo -e "→ Generate smart contracts bindings" +contracts/dev/generate + +echo -e "→ Update Go dependencies" +go mod tidy + +echo -e "→ Start docker containers" dev/docker/up -# Make sure the abis are updated -contracts/dev/generate +echo -e "→ Deploy smart contracts" contracts/dev/deploy-local -echo "Registering local node-1" +echo -e "→ Register local node-1" dev/register-local-node -echo "Registering local node-2" -dev/register-local-node-2 \ No newline at end of file +echo -e "→ Register local node-2" +dev/register-local-node-2 diff --git a/go.mod b/go.mod index 071f13e6..2abaef1e 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( ) require ( + github.com/Masterminds/semver/v3 v3.1.1 github.com/golang-jwt/jwt/v5 v5.2.1 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 github.com/valyala/fastjson v1.6.4 diff --git a/pkg/authn/claims.go b/pkg/authn/claims.go new file mode 100644 index 00000000..67af38eb --- /dev/null +++ b/pkg/authn/claims.go @@ -0,0 +1,41 @@ +package authn + +import ( + "fmt" + "github.com/Masterminds/semver/v3" + "github.com/golang-jwt/jwt/v5" +) + +const ( + // XMTPD_COMPATIBLE_VERSION_CONSTRAINT major or minor serverVersion bumps indicate backwards incompatible changes + XMTPD_COMPATIBLE_VERSION_CONSTRAINT = "~ 0.1.3" +) + +type XmtpdClaims struct { + Version *semver.Version `json:"version,omitempty"` + jwt.RegisteredClaims +} + +func ValidateVersionClaimIsCompatible(claims *XmtpdClaims) error { + if claims.Version == nil { + return nil + } + + c, err := semver.NewConstraint(XMTPD_COMPATIBLE_VERSION_CONSTRAINT) + if err != nil { + return err + } + + // SemVer implementations generally do not consider pre-releases to be valid next releases + // we use SemVer here to allow incoming connections, for which in-development nodes are acceptable + // see discussion in https://github.com/Masterminds/semver/issues/21 + sanitizedVersion, err := claims.Version.SetPrerelease("") + if err != nil { + return err + } + if ok := c.Check(&sanitizedVersion); !ok { + return fmt.Errorf("serverVersion %s is not compatible", *claims.Version) + } + + return nil +} diff --git a/pkg/authn/claims_test.go b/pkg/authn/claims_test.go new file mode 100644 index 00000000..55c7d81c --- /dev/null +++ b/pkg/authn/claims_test.go @@ -0,0 +1,130 @@ +package authn + +import ( + "bytes" + "github.com/Masterminds/semver/v3" + "github.com/stretchr/testify/require" + "github.com/xmtp/xmtpd/pkg/registry" + "github.com/xmtp/xmtpd/pkg/testutils" + "os/exec" + "strings" + "testing" +) + +func getLatestTag(t *testing.T) string { + // Prepare the command + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0") + + // Capture the output + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + + // Run the command + err := cmd.Run() + require.NoError(t, err, out.String()) + return strings.TrimSpace(out.String()) +} + +func getLatestVersion(t *testing.T) semver.Version { + tag := getLatestTag(t) + v, err := semver.NewVersion(tag) + require.NoError(t, err) + + return *v +} + +func newVersionNoError(t *testing.T, version string, pre string, meta string) semver.Version { + v, err := semver.NewVersion(version) + require.NoError(t, err) + + vextras, err := v.SetPrerelease(pre) + require.NoError(t, err) + + vmeta, err := vextras.SetMetadata(meta) + require.NoError(t, err) + + return vmeta +} + +func TestClaimsVerifierNoVersion(t *testing.T) { + signerPrivateKey := testutils.RandomPrivateKey(t) + + tests := []struct { + name string + version *semver.Version + wantErr bool + }{ + {"no-version", nil, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID), tt.version) + + verifier, nodeRegistry := buildVerifier(t, uint32(VERIFIER_NODE_ID)) + nodeRegistry.EXPECT().GetNode(uint32(SIGNER_NODE_ID)).Return(®istry.Node{ + SigningKey: &signerPrivateKey.PublicKey, + NodeID: uint32(SIGNER_NODE_ID), + }, nil) + + token, err := tokenFactory.CreateToken(uint32(VERIFIER_NODE_ID)) + require.NoError(t, err) + verificationError := verifier.Verify(token.SignedString) + if tt.wantErr { + require.Error(t, verificationError) + } else { + require.NoError(t, verificationError) + } + }) + } +} + +func TestClaimsVerifier(t *testing.T) { + signerPrivateKey := testutils.RandomPrivateKey(t) + + currentVersion := getLatestVersion(t) + + tests := []struct { + name string + version semver.Version + wantErr bool + }{ + {"current-version", currentVersion, false}, + {"next-patch-version", currentVersion.IncPatch(), false}, + {"next-minor-version", currentVersion.IncMinor(), true}, + {"next-major-version", currentVersion.IncMajor(), true}, + {"last-supported-version", newVersionNoError(t, currentVersion.String(), "", ""), false}, + { + "with-prerelease-version", + newVersionNoError(t, currentVersion.String(), "17-gdeadbeef", ""), + false, + }, + { + "with-metadata-version", + newVersionNoError(t, currentVersion.String(), "", "branch-dev"), + false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID), &tt.version) + + verifier, nodeRegistry := buildVerifier(t, uint32(VERIFIER_NODE_ID)) + nodeRegistry.EXPECT().GetNode(uint32(SIGNER_NODE_ID)).Return(®istry.Node{ + SigningKey: &signerPrivateKey.PublicKey, + NodeID: uint32(SIGNER_NODE_ID), + }, nil) + + token, err := tokenFactory.CreateToken(uint32(VERIFIER_NODE_ID)) + require.NoError(t, err) + verificationError := verifier.Verify(token.SignedString) + if tt.wantErr { + require.Error(t, verificationError) + } else { + require.NoError(t, verificationError) + } + }) + } +} diff --git a/pkg/authn/signingMethod.go b/pkg/authn/signingMethod.go index 2742c5eb..b699d4ad 100644 --- a/pkg/authn/signingMethod.go +++ b/pkg/authn/signingMethod.go @@ -26,7 +26,7 @@ var ( /* * The JWT signing method for secp256k1. Inspired by https://github.com/ureeves/jwt-go-secp256k1/blob/master/secp256k1.go -but updated to work with the latest version of jwt-go. +but updated to work with the latest serverVersion of jwt-go. */ type SigningMethodSecp256k1 struct{} diff --git a/pkg/authn/tokenFactory.go b/pkg/authn/tokenFactory.go index 67eb5b7a..7daa37a5 100644 --- a/pkg/authn/tokenFactory.go +++ b/pkg/authn/tokenFactory.go @@ -2,6 +2,7 @@ package authn import ( "crypto/ecdsa" + "github.com/Masterminds/semver/v3" "strconv" "time" @@ -13,14 +14,20 @@ const ( ) type TokenFactory struct { - privateKey *ecdsa.PrivateKey - nodeID uint32 + privateKey *ecdsa.PrivateKey + nodeID uint32 + serverVersion *semver.Version } -func NewTokenFactory(privateKey *ecdsa.PrivateKey, nodeID uint32) *TokenFactory { +func NewTokenFactory( + privateKey *ecdsa.PrivateKey, + nodeID uint32, + serverVersion *semver.Version, +) *TokenFactory { return &TokenFactory{ - privateKey: privateKey, - nodeID: nodeID, + privateKey: privateKey, + nodeID: nodeID, + serverVersion: serverVersion, } } @@ -28,12 +35,18 @@ func (f *TokenFactory) CreateToken(forNodeID uint32) (*Token, error) { now := time.Now() expiresAt := now.Add(TOKEN_DURATION) - token := jwt.NewWithClaims(&SigningMethodSecp256k1{}, &jwt.RegisteredClaims{ - Subject: strconv.Itoa(int(f.nodeID)), - Audience: []string{strconv.Itoa(int(forNodeID))}, - ExpiresAt: jwt.NewNumericDate(expiresAt), - IssuedAt: jwt.NewNumericDate(now), - }) + claims := &XmtpdClaims{ + Version: f.serverVersion, + RegisteredClaims: jwt.RegisteredClaims{ + Subject: strconv.Itoa(int(f.nodeID)), + Audience: []string{strconv.Itoa(int(forNodeID))}, + ExpiresAt: jwt.NewNumericDate(expiresAt), + IssuedAt: jwt.NewNumericDate(now), + }, + } + + // Create a new token with custom claims + token := jwt.NewWithClaims(&SigningMethodSecp256k1{}, claims) signedString, err := token.SignedString(f.privateKey) if err != nil { diff --git a/pkg/authn/tokenFactory_test.go b/pkg/authn/tokenFactory_test.go index 33e09d5d..3ebd991b 100644 --- a/pkg/authn/tokenFactory_test.go +++ b/pkg/authn/tokenFactory_test.go @@ -1,6 +1,7 @@ package authn import ( + "github.com/Masterminds/semver/v3" "testing" "time" @@ -10,7 +11,7 @@ import ( func TestTokenFactory(t *testing.T) { privateKey := testutils.RandomPrivateKey(t) - factory := NewTokenFactory(privateKey, 100) + factory := NewTokenFactory(privateKey, 100, nil) token, err := factory.CreateToken(200) require.NoError(t, err) @@ -20,3 +21,29 @@ func TestTokenFactory(t *testing.T) { require.True(t, token.ExpiresAt.After(time.Now().Add(59*time.Minute))) require.True(t, token.ExpiresAt.Before(time.Now().Add(61*time.Minute))) } + +func TestTokenFactoryWithVersion(t *testing.T) { + privateKey := testutils.RandomPrivateKey(t) + + tests := []struct { + name string + version string + }{ + {"current-ish", "0.1.3"}, + {"future-ish", "11.7.3"}, + {"with-git-describe", "0.1.0-15-gdeadbeef"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + version, err := semver.NewVersion(tt.version) + require.NoError(t, err) + factory := NewTokenFactory(privateKey, 100, version) + + token, err := factory.CreateToken(200) + require.NoError(t, err) + require.NotNil(t, token) + }) + } + +} diff --git a/pkg/authn/verifier.go b/pkg/authn/verifier.go index 5cef5080..bd587beb 100644 --- a/pkg/authn/verifier.go +++ b/pkg/authn/verifier.go @@ -30,10 +30,14 @@ func NewRegistryVerifier(registry registry.NodeRegistry, myNodeID uint32) *Regis func (v *RegistryVerifier) Verify(tokenString string) error { var token *jwt.Token var err error - if token, err = jwt.Parse(tokenString, v.getMatchingPublicKey); err != nil { + + if token, err = jwt.ParseWithClaims( + tokenString, + &XmtpdClaims{}, + v.getMatchingPublicKey, + ); err != nil { return err } - if err = v.validateAudience(token); err != nil { return err } @@ -42,6 +46,10 @@ func (v *RegistryVerifier) Verify(tokenString string) error { return err } + if err = v.validateClaims(token); err != nil { + return err + } + return nil } @@ -85,6 +93,20 @@ func (v *RegistryVerifier) validateAudience(token *jwt.Token) error { return fmt.Errorf("could not find node ID in audience %v", audience) } +func (v *RegistryVerifier) validateClaims(token *jwt.Token) error { + claims, ok := token.Claims.(*XmtpdClaims) + if !ok { + return fmt.Errorf("invalid token claims type") + } + + // Check if the token is valid + if !token.Valid { + return fmt.Errorf("invalid token") + } + + return ValidateVersionClaimIsCompatible(claims) +} + // Parse the subject claim of the JWT and return the node ID as a uint32 func getSubjectNodeId(token *jwt.Token) (uint32, error) { subject, err := token.Claims.GetSubject() diff --git a/pkg/authn/verifier_test.go b/pkg/authn/verifier_test.go index 944fff69..17fa1d36 100644 --- a/pkg/authn/verifier_test.go +++ b/pkg/authn/verifier_test.go @@ -53,7 +53,7 @@ func buildJwt( func TestVerifier(t *testing.T) { signerPrivateKey := testutils.RandomPrivateKey(t) - tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID)) + tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID), nil) verifier, nodeRegistry := buildVerifier(t, uint32(VERIFIER_NODE_ID)) nodeRegistry.EXPECT().GetNode(uint32(SIGNER_NODE_ID)).Return(®istry.Node{ @@ -79,7 +79,7 @@ func TestVerifier(t *testing.T) { func TestWrongAudience(t *testing.T) { signerPrivateKey := testutils.RandomPrivateKey(t) - tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID)) + tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID), nil) verifier, nodeRegistry := buildVerifier(t, uint32(VERIFIER_NODE_ID)) nodeRegistry.EXPECT().GetNode(uint32(SIGNER_NODE_ID)).Return(®istry.Node{ @@ -97,7 +97,7 @@ func TestWrongAudience(t *testing.T) { func TestUnknownNode(t *testing.T) { signerPrivateKey := testutils.RandomPrivateKey(t) - tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID)) + tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID), nil) verifier, nodeRegistry := buildVerifier(t, uint32(VERIFIER_NODE_ID)) nodeRegistry.EXPECT().GetNode(uint32(SIGNER_NODE_ID)).Return(nil, errors.New("node not found")) @@ -112,7 +112,7 @@ func TestUnknownNode(t *testing.T) { func TestWrongPublicKey(t *testing.T) { signerPrivateKey := testutils.RandomPrivateKey(t) - tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID)) + tokenFactory := NewTokenFactory(signerPrivateKey, uint32(SIGNER_NODE_ID), nil) verifier, nodeRegistry := buildVerifier(t, uint32(VERIFIER_NODE_ID)) diff --git a/pkg/interceptors/client/auth_test.go b/pkg/interceptors/client/auth_test.go index ed6b86ef..ffb876b3 100644 --- a/pkg/interceptors/client/auth_test.go +++ b/pkg/interceptors/client/auth_test.go @@ -55,7 +55,7 @@ func TestAuthInterceptor(t *testing.T) { privateKey := testutils.RandomPrivateKey(t) myNodeID := uint32(100) targetNodeID := uint32(200) - tokenFactory := authn.NewTokenFactory(privateKey, myNodeID) + tokenFactory := authn.NewTokenFactory(privateKey, myNodeID, nil) interceptor := NewAuthInterceptor(tokenFactory, targetNodeID) token, err := interceptor.getToken() require.NoError(t, err) diff --git a/pkg/registrant/registrant.go b/pkg/registrant/registrant.go index ebe093ff..7b1555e5 100644 --- a/pkg/registrant/registrant.go +++ b/pkg/registrant/registrant.go @@ -5,6 +5,7 @@ import ( "context" "crypto/ecdsa" "fmt" + "github.com/Masterminds/semver/v3" "slices" "go.uber.org/zap" @@ -31,6 +32,7 @@ func NewRegistrant( db *queries.Queries, nodeRegistry registry.NodeRegistry, privateKeyString string, + serverVersion *semver.Version, ) (*Registrant, error) { privateKey, err := utils.ParseEcdsaPrivateKey(privateKeyString) if err != nil { @@ -47,7 +49,7 @@ func NewRegistrant( return nil, err } - tokenFactory := authn.NewTokenFactory(privateKey, record.NodeID) + tokenFactory := authn.NewTokenFactory(privateKey, record.NodeID, serverVersion) log.Info( "Registrant identified", diff --git a/pkg/registrant/registrant_test.go b/pkg/registrant/registrant_test.go index 76cd88a5..c74551f9 100644 --- a/pkg/registrant/registrant_test.go +++ b/pkg/registrant/registrant_test.go @@ -3,6 +3,7 @@ package registrant_test import ( "context" "crypto/ecdsa" + "github.com/Masterminds/semver/v3" "testing" "time" @@ -29,6 +30,7 @@ type deps struct { privKey1Str string privKey2 *ecdsa.PrivateKey privKey3 *ecdsa.PrivateKey + version *semver.Version } func setup(t *testing.T) (deps, func()) { @@ -54,6 +56,7 @@ func setup(t *testing.T) (deps, func()) { privKey1Str: privKey1Str, privKey2: privKey2, privKey3: privKey3, + version: nil, }, dbCleanup } @@ -70,6 +73,7 @@ func setupWithRegistrant(t *testing.T) (deps, *registrant.Registrant, func()) { deps.db, deps.registry, deps.privKey1Str, + deps.version, ) require.NoError(t, err) @@ -86,6 +90,7 @@ func TestNewRegistrantBadPrivateKey(t *testing.T) { deps.db, deps.registry, "badkey", + deps.version, ) require.ErrorContains(t, err, "parse") } @@ -105,6 +110,7 @@ func TestNewRegistrantNotInRegistry(t *testing.T) { deps.db, deps.registry, deps.privKey1Str, + deps.version, ) require.ErrorContains(t, err, "registry") } @@ -125,6 +131,7 @@ func TestNewRegistrantNewDatabase(t *testing.T) { deps.db, deps.registry, deps.privKey1Str, + deps.version, ) require.NoError(t, err) } @@ -152,6 +159,7 @@ func TestNewRegistrantExistingDatabase(t *testing.T) { deps.db, deps.registry, deps.privKey1Str, + deps.version, ) require.NoError(t, err) } @@ -179,6 +187,7 @@ func TestNewRegistrantMismatchingDatabaseNodeId(t *testing.T) { deps.db, deps.registry, deps.privKey1Str, + deps.version, ) require.ErrorContains(t, err, "does not match") } @@ -206,6 +215,7 @@ func TestNewRegistrantMismatchingDatabasePublicKey(t *testing.T) { deps.db, deps.registry, deps.privKey1Str, + deps.version, ) require.ErrorContains(t, err, "does not match") } @@ -224,6 +234,7 @@ func TestNewRegistrantPrivateKeyNo0x(t *testing.T) { deps.db, deps.registry, utils.HexEncode(crypto.FromECDSA(deps.privKey1)), + deps.version, ) require.NoError(t, err) } diff --git a/pkg/server/server.go b/pkg/server/server.go index f3bd647b..30068bbf 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -8,28 +8,28 @@ import ( "os/signal" "syscall" - "github.com/xmtp/xmtpd/pkg/authn" - "github.com/xmtp/xmtpd/pkg/mlsvalidate" - + "github.com/Masterminds/semver/v3" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" + "go.uber.org/zap" + "google.golang.org/grpc" + + "github.com/xmtp/xmtpd/pkg/api" "github.com/xmtp/xmtpd/pkg/api/message" "github.com/xmtp/xmtpd/pkg/api/payer" + "github.com/xmtp/xmtpd/pkg/authn" "github.com/xmtp/xmtpd/pkg/blockchain" + "github.com/xmtp/xmtpd/pkg/config" + "github.com/xmtp/xmtpd/pkg/db/queries" "github.com/xmtp/xmtpd/pkg/indexer" "github.com/xmtp/xmtpd/pkg/metrics" + "github.com/xmtp/xmtpd/pkg/mlsvalidate" "github.com/xmtp/xmtpd/pkg/proto/xmtpv4/message_api" "github.com/xmtp/xmtpd/pkg/proto/xmtpv4/payer_api" - "github.com/xmtp/xmtpd/pkg/sync" - "github.com/xmtp/xmtpd/pkg/utils" - "google.golang.org/grpc" - - "github.com/xmtp/xmtpd/pkg/api" - "github.com/xmtp/xmtpd/pkg/config" - "github.com/xmtp/xmtpd/pkg/db/queries" "github.com/xmtp/xmtpd/pkg/registrant" "github.com/xmtp/xmtpd/pkg/registry" - "go.uber.org/zap" + "github.com/xmtp/xmtpd/pkg/sync" + "github.com/xmtp/xmtpd/pkg/utils" ) type ReplicationServer struct { @@ -55,6 +55,7 @@ func NewReplicationServer( writerDB *sql.DB, blockchainPublisher blockchain.IBlockchainPublisher, listenAddress string, + serverVersion *semver.Version, ) (*ReplicationServer, error) { var err error @@ -92,6 +93,7 @@ func NewReplicationServer( queries.New(writerDB), nodeRegistry, options.Signer.PrivateKey, + serverVersion, ) if err != nil { return nil, err diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 4f72a005..3c814cba 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -65,7 +65,7 @@ func NewTestServer( //Payer: config.PayerOptions{ // Enable: true, //}, - }, registry, db, messagePublisher, fmt.Sprintf("localhost:%d", port)) + }, registry, db, messagePublisher, fmt.Sprintf("localhost:%d", port), nil) require.NoError(t, err) return server diff --git a/pkg/testutils/api/api.go b/pkg/testutils/api/api.go index 29a502d4..6429d9f6 100644 --- a/pkg/testutils/api/api.go +++ b/pkg/testutils/api/api.go @@ -82,7 +82,14 @@ func NewTestAPIServer(t *testing.T) (*api.ApiServer, *sql.DB, ApiServerMocks, fu mockRegistry.EXPECT().GetNodes().Return([]registry.Node{ {NodeID: 100, SigningKey: &privKey.PublicKey}, }, nil) - registrant, err := registrant.NewRegistrant(ctx, log, queries.New(db), mockRegistry, privKeyStr) + registrant, err := registrant.NewRegistrant( + ctx, + log, + queries.New(db), + mockRegistry, + privKeyStr, + nil, + ) require.NoError(t, err) mockMessagePublisher := blockchain.NewMockIBlockchainPublisher(t) mockValidationService := mlsvalidateMocks.NewMockMLSValidationService(t) diff --git a/pkg/testutils/config.go b/pkg/testutils/config.go index af586c58..3df237bb 100644 --- a/pkg/testutils/config.go +++ b/pkg/testutils/config.go @@ -98,7 +98,10 @@ func GetContractsOptions(t *testing.T) config.ContractsOptions { t, path.Join(rootDir, "./contracts/config/anvil_localnet/GroupMessages.json"), ), - NodesContractAddress: getDeployedTo(t, path.Join(rootDir, "./build/Nodes.json")), + NodesContractAddress: getDeployedTo( + t, + path.Join(rootDir, "./contracts/config/anvil_localnet/Nodes.json"), + ), IdentityUpdatesContractAddress: getProxyAddress( t, path.Join(rootDir, "./contracts/config/anvil_localnet/IdentityUpdates.json"),