Skip to content

feat: Custodial key management for Canton user registration#53

Merged
kalambet merged 6 commits into
mainfrom
salindne/custodial
Feb 3, 2026
Merged

feat: Custodial key management for Canton user registration#53
kalambet merged 6 commits into
mainfrom
salindne/custodial

Conversation

@salindne
Copy link
Copy Markdown
Contributor

Summary

  • Add custodial Canton key management for user registration flow
  • Users sign with EVM wallet (MetaMask), API server manages Canton keys on their behalf
  • Canton parties allocated dynamically via Admin API (AllocateParty)
  • Keys encrypted with AES-256-GCM and stored in PostgreSQL

Changes

  • pkg/keys: New package for Ed25519 keypair generation and encryption
  • pkg/canton/lapi/v2/admin: Proto bindings for Canton Admin API (PartyManagementService)
  • pkg/registration/handler.go: Updated to allocate parties and store encrypted keys
  • pkg/apidb/users.go: Extended schema for custodial key storage
  • scripts/demo-activity.go: Now shows PROMPT vs DEMO token breakdown correctly
  • configs: Updated with v1.2.0 DAML package IDs

Requirements for DevNet Deployment

The API server now requires:

  1. CANTON_MASTER_KEY env var (generate with: openssl rand -base64 32)
  2. OAuth token with ParticipantAdmin rights for party allocation
  3. Access to Canton Admin API endpoint

Test plan

  • Tested locally with Docker Compose setup
  • Verified user registration creates Canton party
  • Verified MetaMask can query balances and send transfers
  • Test on DevNet with ChainSafe node

- Add pkg/keys for Canton Ed25519 keypair generation and AES-256-GCM encryption
- Add pkg/canton/lapi/v2/admin proto bindings for PartyManagementService
- Update registration handler to allocate Canton parties via Admin API
- Store encrypted Canton keys in PostgreSQL for custodial management
- Update demo-activity script to show PROMPT vs DEMO token breakdown
- Update configs with v1.2.0 DAML package IDs
- Add key_management config section for master key env var

This enables the API server to create Canton parties for users during
registration and manage their signing keys custodially, allowing EVM
wallet signatures to authorize Canton ledger operations.
Points to salindne/remove-issuer-transfer branch which removes the
IssuerTransfer choice, now unnecessary with custodial key model.
@gemini-code-assist
Copy link
Copy Markdown
Contributor

Summary of Changes

Hello @salindne, 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 significant architectural shift by enabling custodial key management for user registrations on Canton. Users can now register and have a unique Canton party and associated keys managed by the API server, enhancing decentralization and user ownership of assets. This change moves the system from an issuer-centric model to one where the API server acts as a custodian, signing transactions on behalf of individual users with their dedicated Canton keys. This improves the security posture and aligns with principles of self-sovereign identity within the Canton ecosystem.

Highlights

  • Custodial Key Management: Implemented custodial key management for Canton user registration, allowing the API server to generate, encrypt, and store Canton private keys on behalf of users. This enables users to directly control their assets on Canton via the API server.
  • Dynamic Canton Party Allocation: User registration now dynamically allocates a unique Canton party for each user via the Canton Admin API's AllocateParty service, moving away from a single relayer party for all users.
  • User-Controlled Token Transfers: Token transfer mechanisms (PROMPT and DEMO) have been updated to utilize the new custodial keys. Transfers are now executed by the API server signing commands as the user's allocated Canton party, exercising the CIP56Holding.Transfer choice directly, rather than using issuer-centric burn/mint operations.
  • Database Schema and Configuration Updates: The users table in the PostgreSQL database has been extended to store encrypted Canton private keys and associated metadata. Configuration files have been updated to include settings for key management and new Canton package IDs (v1.2.0).
  • Canton Admin API Integration: New protobuf bindings and client methods have been added to interact with Canton's Admin API, specifically for party management, which is crucial for dynamic party allocation.

🧠 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.

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
Copy Markdown
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 significant architectural change by implementing a custodial key management system for Canton user registration. The API server now generates, encrypts, and manages Canton keys on behalf of users, which is a robust approach for simplifying user onboarding. The implementation correctly uses the Canton Admin API for dynamic party allocation and AES-256-GCM for secure key encryption.

While the overall design is solid, I've identified a few critical issues. There are several database queries that omit the demo_balance column, which will lead to incorrect balance data for users. Additionally, the demo token transfer logic in the token service is incomplete, as it fails to increment the recipient's balance in the cache. I've also raised a high-severity concern about the non-atomic nature of user creation and key storage during registration, which could result in inconsistent user states if an error occurs. Finally, I've suggested a refactoring in the database layer to improve maintainability by reducing code duplication.

Comment thread pkg/apidb/users.go Outdated
Comment on lines 66 to 68
SELECT id, evm_address, canton_party, fingerprint, mapping_cid, balance, balance_updated_at, created_at,
canton_party_id, canton_private_key_encrypted, canton_key_created_at
FROM users
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

The SELECT query is missing the demo_balance column. This will cause the demo_balance field on the returned User struct to be incorrect (it will default to "0"). The Scan call below also needs to be updated to include a variable for demo_balance.

This same issue is present in GetUserByFingerprint (line 127) and GetUsersWithoutCantonKey (line 708). All three queries should be updated to include demo_balance to prevent data inconsistencies.

Suggested change
SELECT id, evm_address, canton_party, fingerprint, mapping_cid, balance, balance_updated_at, created_at,
canton_party_id, canton_private_key_encrypted, canton_key_created_at
FROM users
SELECT id, evm_address, canton_party, fingerprint, mapping_cid, balance, demo_balance, balance_updated_at, created_at,
canton_party_id, canton_private_key_encrypted, canton_key_created_at

Comment on lines 185 to +199
if err := h.db.CreateUser(user); err != nil {
h.logger.Error("Failed to save user", zap.Error(err))
h.writeError(w, http.StatusInternalServerError, "failed to save user")
return
}

// Store the encrypted Canton key
if h.keyStore != nil {
if err := h.keyStore.SetUserKey(evmAddress, cantonPartyID, cantonKeyPair.PrivateKey); err != nil {
h.logger.Error("Failed to store Canton key",
zap.String("evm_address", evmAddress),
zap.Error(err))
// Don't fail registration - key can be regenerated later
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The user creation and key storage are performed in two separate, non-atomic database operations: an INSERT via db.CreateUser followed by an UPDATE within keyStore.SetUserKey. If the second operation fails, a user record will exist in the database without an associated key.

The comment on line 197, // Don't fail registration - key can be regenerated later, is only accurate if keys are derived deterministically. Since the default KeyDerivation method is "generate", the key is random and cannot be regenerated. This could leave the user in an inconsistent state, unable to use features requiring their Canton key.

To ensure data integrity, these operations should be performed within a single database transaction.

kalambet and others added 3 commits January 29, 2026 16:30
Co-authored-by: Cursor <cursoragent@cursor.com>
- Refactor balance functions to use TokenType parameter (TokenPrompt, TokenDemo)
  instead of hardcoded prompt/demo-specific functions
- Rename balance column to prompt_balance for clarity
- Fix non-atomic user registration by adding cleanup on key storage failure
- Add DeleteUser function for registration cleanup
- Fix demo-activity.go SQL query for prompt_balance
- Update Docker config with correct local Anvil addresses (chain ID 31337)
- Add scripts/bootstrap-all.sh for full local environment automation

Co-authored-by: Cursor <cursoragent@cursor.com>
@salindne
Copy link
Copy Markdown
Contributor Author

/gemini review

Copy link
Copy Markdown
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 significant and well-architected feature for custodial key management in the user registration flow. The changes are extensive, touching the database schema, API handlers, Canton client, and introducing a new pkg/keys for cryptographic operations. The use of a KeyStore interface for abstraction and the robust error handling in the registration handler, including cleanup on failure, are commendable. The documentation additions are also very helpful for understanding the new architecture.

My review has identified one high-severity issue related to signature encoding that needs to be addressed to ensure compatibility with Canton, and a minor formatting issue in the documentation. Overall, this is a solid implementation of a complex feature.

Comment thread pkg/keys/canton_keys.go
Comment on lines +173 to +193
func (kp *CantonKeyPair) Sign(message []byte) ([]byte, error) {
// Convert private key bytes to ECDSA private key
privateKey, err := crypto.ToECDSA(kp.PrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to convert private key: %w", err)
}

// Hash the message with SHA-256
hash := sha256.Sum256(message)

// Sign with ECDSA
signature, err := crypto.Sign(hash[:], privateKey)
if err != nil {
return nil, fmt.Errorf("failed to sign: %w", err)
}

// The crypto.Sign function returns a 65-byte signature [R || S || V]
// For Canton, we need just the DER-encoded signature without the recovery ID
// We'll return the R and S values (first 64 bytes)
return signature[:64], nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The Sign function's implementation returns a raw 64-byte [R || S] signature, but the function's comment and Canton's requirements for SIGNING_ALGORITHM_SPEC_EC_DSA_SHA_256 specify a DER-encoded signature. This will likely cause signature verification failures in Canton. The signature should be encoded in ASN.1 DER format.

You can use ecdsa.SignASN1 from the standard library for this. Note that you will need to add import "crypto/ecdsa".

A similar fix should also be applied to the SignHash function.

// Sign signs a message with the private key using ECDSA with SHA-256
// Returns the signature in DER format (compatible with Canton)
func (kp *CantonKeyPair) Sign(message []byte) ([]byte, error) {
	// Convert private key bytes to ECDSA private key
	privateKey, err := crypto.ToECDSA(kp.PrivateKey)
	if err != nil {
		return nil, fmt.Errorf("failed to convert private key: %w", err)
	}

	// Hash the message with SHA-256
	hash := sha256.Sum256(message)

	// Sign and encode as ASN.1 DER, which is what Canton expects.
	signature, err := ecdsa.SignASN1(rand.Reader, privateKey, hash[:])
	if err != nil {
		return nil, fmt.Errorf("failed to create DER-encoded signature: %w", err)
	}

	return signature, nil
}

Comment thread pkg/keys/canton_keys.go
Comment on lines +196 to +212
func (kp *CantonKeyPair) SignHash(hash []byte) ([]byte, error) {
if len(hash) != 32 {
return nil, fmt.Errorf("hash must be 32 bytes")
}

privateKey, err := crypto.ToECDSA(kp.PrivateKey)
if err != nil {
return nil, fmt.Errorf("failed to convert private key: %w", err)
}

signature, err := crypto.Sign(hash, privateKey)
if err != nil {
return nil, fmt.Errorf("failed to sign: %w", err)
}

return signature[:64], nil
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Similar to the Sign function, SignHash returns a raw signature instead of the required DER-encoded format. This should be updated to use ecdsa.SignASN1 to produce a compliant signature for Canton.

func (kp *CantonKeyPair) SignHash(hash []byte) ([]byte, error) {
	if len(hash) != 32 {
		return nil, fmt.Errorf("hash must be 32 bytes")
	}

	privateKey, err := crypto.ToECDSA(kp.PrivateKey)
	if err != nil {
		return nil, fmt.Errorf("failed to convert private key: %w", err)
	}

	// Sign and encode as ASN.1 DER.
	signature, err := ecdsa.SignASN1(rand.Reader, privateKey, hash)
	if err != nil {
		return nil, fmt.Errorf("failed to create DER-encoded signature for hash: %w", err)
	}

	return signature, nil
}

Scripts reorganization:
- Move setup/bootstrap scripts to scripts/setup/
- Move test/demo scripts to scripts/testing/
- Move bridge operations to scripts/bridge/
- Move diagnostics to scripts/utils/
- Move migration scripts to scripts/archive/
- Delete redundant demo scripts (native-wallet-demo.go, reconcile-demo.go, demo-showcase.sh, interop-demo.sh)

Documentation cleanup:
- Consolidate architecture docs into ARCHITECTURE.md
- Consolidate setup guides into SETUP_AND_TESTING.md
- Rename CIP-0086-OVERVIEW2.md to CIP-0086-OVERVIEW.md
- Update WAYFINDER_DEPLOYMENT_REQUIREMENTS.md
- Delete outdated docs (canton-integration.md, relayer-logic.md, secp256k1-*.md, etc.)
- Move demo docs to docs/demo/ (gitignored)

Code changes:
- Add register-native-user.go for native Canton user registration
- Add canton-transfer-demo.go for transfer demonstrations
- Add query-canton-holdings.sh and verify-canton-holdings.go utilities
- Fix linter error in register-native-user.go (add //go:build ignore)
- Remove hardcoded OAuth secrets from archive scripts

README update:
- Reflect current project structure and script locations
- Update documentation links
- Modernize quick start instructions

Co-authored-by: Cursor <cursoragent@cursor.com>
@salindne
Copy link
Copy Markdown
Contributor Author

salindne commented Feb 3, 2026

/gemini review

Copy link
Copy Markdown
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 significant and well-structured feature: custodial key management for Canton users. This allows the API server to manage Canton keys on behalf of users, enabling them to interact with the Canton network using only their EVM wallet. The changes are extensive, touching documentation, configuration, database schema, and core application logic. New packages for key generation/encryption (pkg/keys) and Canton signature authentication (pkg/auth) have been added. The registration flow is significantly updated to allocate unique Canton parties and store encrypted keys, and the token transfer logic is refactored to a more appropriate user-centric model. My review includes suggestions for improving database migration safety, error handling in the registration flow, and clarity in the new cryptography package.

Comment on lines 15 to 29
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
evm_address VARCHAR(42) UNIQUE NOT NULL,
canton_party VARCHAR(255) NOT NULL,
canton_party VARCHAR(255) NOT NULL, -- User's Canton party ID
fingerprint VARCHAR(128) NOT NULL,
mapping_cid VARCHAR(255),
balance DECIMAL(38,18) DEFAULT 0,
demo_balance DECIMAL(38,18) DEFAULT 0, -- DEMO (native) token balance
prompt_balance DECIMAL(38,18) DEFAULT 0, -- PROMPT (bridged) token balance
demo_balance DECIMAL(38,18) DEFAULT 0, -- DEMO (native) token balance
balance_updated_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
created_at TIMESTAMP DEFAULT NOW(),
-- Custodial Canton key fields
canton_party_id VARCHAR(255), -- User's own Canton party (same as canton_party for new users)
canton_private_key_encrypted TEXT, -- AES-256-GCM encrypted Canton private key (base64)
canton_key_created_at TIMESTAMP -- When the Canton key was generated
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The schema for the users table has been updated, including renaming the balance column to prompt_balance and adding new columns for key management. The CREATE TABLE IF NOT EXISTS statement will not apply these changes to an existing database, which will cause errors in existing deployments.

A migration script is needed to safely alter the table for existing installations. You could add a DO ... END block to check for the existence of the old column and rename it, and to add the new columns if they don't exist.

Fingerprint: fingerprint,
MappingCID: mappingCID,
EVMAddress: evmAddress,
PrivateKey: evmKeyPair.PrivateKeyHex(), // For MetaMask import
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Returning a private key in an API response is highly sensitive, even for a user import flow. While this enables the user to import the generated EVM identity into MetaMask, it's crucial to ensure this endpoint is well-protected.

The documentation should strongly emphasize that this endpoint MUST be served over TLS and that clients should treat this key as extremely sensitive, clearing it from memory as soon as it's used.

Comment thread pkg/apidb/users.go
Comment on lines +19 to +27
func tokenColumn(token TokenType) string {
switch token {
case TokenPrompt:
return "prompt_balance"
case TokenDemo:
return "demo_balance"
default:
return "prompt_balance" // Default to PROMPT for safety
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The tokenColumn function defaults to prompt_balance for any unknown TokenType. While this provides a safe fallback, it could mask bugs where an invalid or new, unhandled token type is passed to a balance function. This might lead to incorrect balance updates for the default token instead of failing fast.

Consider panicking or returning an error for an unknown token type to make the behavior more explicit and prevent silent failures.

Comment thread pkg/keys/canton_keys.go

// Sign signs a message with the private key using ECDSA with SHA-256
// Returns the signature in DER format (compatible with Canton)
func (kp *CantonKeyPair) Sign(message []byte) ([]byte, error) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The comment states that the function returns the signature in DER format. However, the implementation return signature[:64], nil returns the raw R and S values concatenated, which is not a valid DER-encoded signature. This is misleading and could cause confusion or errors if a consumer of this function expects a standard DER signature.

Please update the comment to accurately reflect the returned format (e.g., "64-byte R || S format").

Comment on lines +216 to +230
if h.keyStore != nil {
if err := h.keyStore.SetUserKey(evmAddress, cantonPartyID, cantonKeyPair.PrivateKey); err != nil {
h.logger.Error("Failed to store Canton key",
zap.String("evm_address", evmAddress),
zap.Error(err))
// Cleanup: delete the user we just created to maintain consistency
if delErr := h.db.DeleteUser(evmAddress); delErr != nil {
h.logger.Error("Failed to cleanup user after key storage failure",
zap.String("evm_address", evmAddress),
zap.Error(delErr))
}
h.writeError(w, http.StatusInternalServerError, "failed to store Canton key")
return
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

The error handling for keyStore.SetUserKey correctly cleans up the user from the local database. However, the party previously allocated via AllocateParty and the mapping created via RegisterUser on Canton are not rolled back. This could lead to orphaned resources on the Canton ledger.

Consider implementing a compensating action to de-allocate or disable the Canton party if the full registration process fails after it has been created.

@kalambet kalambet self-requested a review February 3, 2026 15:23
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.

3 participants