Skip to content

feat: add two-step external user registration (#134)#154

Merged
salindne merged 6 commits intofeature/111-c-http-apifrom
feature/111-d-registration
Mar 17, 2026
Merged

feat: add two-step external user registration (#134)#154
salindne merged 6 commits intofeature/111-c-http-apifrom
feature/111-d-registration

Conversation

@salindne
Copy link
Contributor

@salindne salindne commented Mar 9, 2026

Summary

Sub-issue D (#134) of the non-custodial signing feature (#111).

  • Two-step external registration:
    1. POST /register/prepare-topology — verify EIP-191 sig, validate whitelist, derive SPKI public key from compressed secp256k1, call Canton GenerateExternalPartyTopology, return topology hash + fingerprint + registration token
    2. POST /register (with key_mode=external) — retrieve pending topology from cache, verify SPKI key consistency between steps, call Canton AllocateExternalPartyWithSignature, create fingerprint mapping, save user as key_mode=external
  • Canton SDK Identity: Split AllocateExternalParty into GenerateExternalPartyTopology and AllocateExternalPartyWithSignature
  • TopologyCache: In-memory cache (5-min TTL) for pending registration topologies
  • Safety checks: User existence re-check before save (race prevention), SPKI key consistency between step 1 and step 2, registration token redaction in logs
  • Domain model: Add external fields to RegisterRequest, PrepareTopologyResponse type, KeyMode to RegisterResponse

Test plan

  • go build ./... — compiles
  • go test ./pkg/user/service/... — registration tests pass
  • go test ./... — all tests pass
  • make lint — passes

Stacked PR 3/4 for #111 — depends on #153

@gemini-code-assist
Copy link
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request introduces a robust two-step external user registration process, enabling non-custodial user onboarding by allowing clients to sign topology transactions with their own keys. This change enhances security and decentralization by delegating key management to the user, while ensuring proper integration with the Canton ledger through a carefully designed API and internal service logic. The new flow is supported by updates to the Canton SDK integration, a dedicated in-memory cache for pending topologies, and comprehensive safety checks.

Highlights

  • Two-Step External User Registration: Implemented a new two-step process for external (non-custodial) user registration. The first step, POST /register/prepare-topology, verifies an EIP-191 signature, validates a whitelist, derives an SPKI public key, calls Canton's GenerateExternalPartyTopology, and returns a topology hash, fingerprint, and registration token. The second step, POST /register with key_mode=external, retrieves the pending topology from a cache, verifies SPKI key consistency, calls Canton's AllocateExternalPartyWithSignature, creates a fingerprint mapping, and saves the user.
  • Canton SDK Identity Updates: The Canton SDK's Identity interface and client implementation have been refactored to split the AllocateExternalParty functionality into two distinct methods: GenerateExternalPartyTopology and AllocateExternalPartyWithSignature, supporting the new two-step registration flow.
  • Topology Cache Introduction: A new in-memory TopologyCache with a 5-minute TTL has been added to temporarily store pending registration topologies between the two steps of external user registration. This cache includes a background cleanup routine.
  • Enhanced Safety Checks: New safety measures include re-checking user existence before saving to prevent race conditions, verifying SPKI key consistency between the two registration steps, and redacting sensitive registration tokens in logs.
  • Domain Model Expansion: The RegisterRequest struct has been extended with new fields for external registration, including KeyMode, CantonPublicKey, RegistrationToken, and TopologySignature. A new PrepareTopologyResponse type has been introduced, and KeyMode was added to RegisterResponse.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • pkg/app/api/server.go
    • Added topologyCacheTTL constant for the new topology cache.
    • Initialized TopologyCache and started its cleanup goroutine.
    • Passed the new topologyCache instance to the userservice.NewService constructor.
  • pkg/cantonsdk/identity/client.go
    • Extended the Identity interface with GenerateExternalPartyTopology and AllocateExternalPartyWithSignature methods.
    • Implemented GenerateExternalPartyTopology to create topology transactions and a multi-hash for external party allocation.
    • Implemented AllocateExternalPartyWithSignature to complete external party allocation using a client-provided DER signature.
  • pkg/cantonsdk/identity/types.go
    • Explicitly imported the errors package.
    • Defined a new ExternalPartyTopology struct to hold intermediate state for external party allocation.
  • pkg/user/service/http.go
    • Added a new POST /register/prepare-topology route to handle the first step of external user registration.
    • Implemented the prepareTopology HTTP handler to process requests for generating external party topology.
  • pkg/user/service/log.go
    • Added logging for the new PrepareExternalRegistration service method, including start, success, and error states.
    • Introduced a redactToken helper function to safely log registration tokens by redacting most of their content.
  • pkg/user/service/mocks/mock_service.go
    • Generated a mock implementation for the new PrepareExternalRegistration method on the Service interface.
  • pkg/user/service/service.go
    • Imported bytes, crypto/elliptic, and github.com/google/uuid packages.
    • Updated the Service interface to include the PrepareExternalRegistration method.
    • Added a topologyCache field to the registrationService struct and its constructor.
    • Modified RegisterWeb3User to conditionally call registerExternalWeb3User if KeyModeExternal is specified.
    • Implemented registerExternalWeb3User to handle the second step of external registration, including cache retrieval, key consistency checks, Canton allocation, and user saving.
    • Implemented PrepareExternalRegistration to handle the first step of external registration, including signature verification, whitelist checks, SPKI derivation, Canton topology generation, and caching.
    • Added compressedKeyToSPKI helper function to convert compressed secp256k1 public keys to SPKI DER format.
  • pkg/user/service/service_test.go
    • Updated calls to NewService in existing tests to pass a nil topologyCache argument, accommodating the new constructor signature.
  • pkg/user/service/topology_cache.go
    • Added a new file defining TopologyCache for storing pending external registration topologies.
    • Implemented NewTopologyCache to create a new cache instance with a specified TTL.
    • Provided Put method to store topology data with an expiration time.
    • Implemented GetAndDelete to atomically retrieve and remove a topology, handling not found and expired cases.
    • Added Start method to run a background goroutine for periodic cleanup of expired entries.
  • pkg/user/user.go
    • Updated comments for RegisterRequest to reflect the new three registration modes, including external (non-custodial).
    • Added KeyMode, CantonPublicKey, RegistrationToken, and TopologySignature fields to RegisterRequest for external registration.
    • Added a KeyMode field to RegisterResponse to indicate the type of key management.
    • Defined a new PrepareTopologyResponse struct for the response of the first step of external registration.
Activity
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a two-step registration flow for external (non-custodial) users, with logic split across the Canton client, user service, and API layers, and includes security considerations like public key consistency checks. However, a high-severity security vulnerability has been identified: the registration whitelist is bypassed during the second step of the external registration process. Specifically, the RegisterWeb3User function returns early for external registrations before performing the whitelist check, and the subsequent registerExternalWeb3User function does not re-verify the whitelist status of the EVM address, allowing an attacker to register unauthorized addresses. Additionally, there is a critical lack of unit tests for this new, complex registration flow, and the use of an in-memory TopologyCache introduces a scalability bottleneck that will cause issues in a multi-instance deployment.

storeMock.EXPECT().UserExists(ctx, evmAddress).Return(true, nil).Once()

svc := NewService(storeMock, nil, nil, zap.NewNop(), false)
svc := NewService(storeMock, nil, nil, zap.NewNop(), false, nil)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

This pull request introduces a critical and complex feature for two-step external user registration, but lacks corresponding unit tests. The modifications in this test file only ensure compilation. To ensure the correctness and security of the new flow, please add comprehensive unit tests for PrepareExternalRegistration and registerExternalWeb3User in pkg/user/service/service.go.

These tests should cover:

  • The happy path for the full two-step registration.
  • Failure scenarios such as invalid signatures, non-whitelisted users, and already-registered users.
  • Security checks, especially the public key consistency validation between step 1 and 2.
  • Edge cases with the TopologyCache, like using an expired, invalid, or already-used token.

Comment on lines +122 to +124
if req.KeyMode == user.KeyModeExternal {
return s.registerExternalWeb3User(ctx, evmAddress, req)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The RegisterWeb3User function handles both custodial and external (non-custodial) registration. For external registration (req.KeyMode == user.KeyModeExternal), it calls registerExternalWeb3User and returns immediately on line 123. However, the whitelist check on line 136 occurs after this call, meaning it is bypassed for external registration. Furthermore, registerExternalWeb3User (lines 313-397) does not perform its own whitelist check.

While the first step of external registration (PrepareExternalRegistration) does check the whitelist, an attacker with one whitelisted address can obtain a registration_token and then use it to register a different, non-whitelisted address in the second step. This is because registerExternalWeb3User does not verify that the evmAddress being registered matches the one that was whitelisted in the first step, nor does it re-check the whitelist for the new address.

func (s *registrationService) RegisterWeb3User(
	ctx context.Context,
	req *user.RegisterRequest,
) (*user.RegisterResponse, error) {
	// Verify EVM signature
	recoveredAddr, err := auth.VerifyEIP191Signature(req.Message, req.Signature)
	if err != nil {
		return nil, apperrors.BadRequestError(err, "invalid signature")
	}

	evmAddress := auth.NormalizeAddress(recoveredAddr.Hex())
	s.logger.Info("Web3 registration initiated",
		zap.String("evm_address", evmAddress),
		zap.String("key_mode", req.KeyMode))

	// Check whitelist before proceeding with any registration type
	whitelisted, err := s.store.IsWhitelisted(ctx, evmAddress)
	if err != nil {
		return nil, fmt.Errorf("failed to check whitelist: %w", err)
	}
	if !whitelisted {
		return nil, apperrors.ForbiddenError(ErrNotWhitelisted, "address not whitelisted for registration")
	}

	// External (non-custodial) registration: second step of two-step flow
	if req.KeyMode == user.KeyModeExternal {
		return s.registerExternalWeb3User(ctx, evmAddress, req)
	}

	// Check if user already exists
	exists, err := s.store.UserExists(ctx, evmAddress)
	if err != nil {
		return nil, fmt.Errorf("failed to check user existence: %w", err)
	}
	if exists {
		return nil, apperrors.ConflictError(ErrUserAlreadyRegistered, "user already registered")
	}

	// ... rest of the function ...

Comment on lines +28 to +33
// TopologyCache stores pending topology data for two-step external user registration.
type TopologyCache struct {
mu sync.RWMutex
entries map[string]*pendingTopology
ttl time.Duration
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The new TopologyCache is implemented as an in-memory cache local to a single server instance. This creates a significant scalability limitation. If this service is deployed with multiple instances behind a load balancer, the two-step registration flow will be unreliable, as step 1 and step 2 of a registration may be handled by different instances. The instance handling step 2 would not have the pending topology information in its memory, causing the registration to fail.

For a scalable architecture, consider replacing this with a distributed cache solution (e.g., Redis, Memcached). If this service is intended to always run as a single instance, this constraint should be clearly documented.

@salindne salindne force-pushed the feature/111-c-http-api branch from 8b68b03 to 5e10849 Compare March 10, 2026 19:56
@salindne salindne force-pushed the feature/111-d-registration branch from 469603b to 080da58 Compare March 10, 2026 19:56
@salindne salindne force-pushed the feature/111-c-http-api branch from 5e10849 to 90ad5d9 Compare March 13, 2026 20:28
@salindne salindne force-pushed the feature/111-d-registration branch from 080da58 to 61570d1 Compare March 13, 2026 20:28
@salindne salindne force-pushed the feature/111-c-http-api branch from 90ad5d9 to 7c80a3f Compare March 17, 2026 17:35
@salindne salindne force-pushed the feature/111-d-registration branch from 61570d1 to cb733e9 Compare March 17, 2026 17:35
Support non-custodial (external) user registration via two HTTP calls:

1. POST /register/prepare-topology — verify EIP-191 signature, validate
   whitelist, derive SPKI public key from compressed secp256k1 key,
   call Canton GenerateExternalPartyTopology, return topology hash +
   fingerprint + registration token for client-side signing.

2. POST /register (key_mode=external) — retrieve pending topology from
   cache, verify SPKI key consistency between steps, call Canton
   AllocateExternalPartyWithSignature with client's DER signature,
   create fingerprint mapping, save user as key_mode=external.

Canton SDK: split AllocateExternalParty into GenerateExternalPartyTopology
and AllocateExternalPartyWithSignature for the two-step flow.

Add TopologyCache (in-memory, 5-min TTL) for pending registrations.
Add PrepareTopologyResponse and external fields to RegisterRequest.
Extend logging decorator with PrepareExternalRegistration wrapper.
The whitelist check was after the KeyModeExternal early return,
meaning external registrations bypassed it. While PrepareExternalRegistration
also checks the whitelist, defense-in-depth requires the check at both
entry points.
Integration test script (test-prepare-execute.go) covering:
- Happy path: register external user, mint, prepare, sign, execute
- Expired transfer (past TTL → 410)
- Replay prevention (double execute → 404)
- Wrong fingerprint (→ 403)
- Custodial rejection (→ 400)

Add docs/non-custodial-snap-testing.md with complete Snap integration
guide: cryptographic requirements, API reference, error tables, RPC
methods, JavaScript dapp example, and curl testing commands.

Update e2e-local config with test user addresses.
…ment

Update mock expectations in registration tests to match the new
whitelist-before-UserExists ordering. Also remove no-op `_ = hash`
in test script.
@salindne salindne force-pushed the feature/111-c-http-api branch from 7c80a3f to 75e867d Compare March 17, 2026 17:44
@salindne salindne force-pushed the feature/111-d-registration branch from cb733e9 to 23d9cb0 Compare March 17, 2026 17:44
feat: add non-custodial integration test and Snap docs (#135)
@salindne salindne merged commit 44b9fbb into feature/111-c-http-api Mar 17, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant