Skip to content

wallet: impl keyvault lifecycle#1273

Open
GustavoStingelin wants to merge 11 commits into
sql-walletfrom
sqldb/impl-keyvault-lifecycle
Open

wallet: impl keyvault lifecycle#1273
GustavoStingelin wants to merge 11 commits into
sql-walletfrom
sqldb/impl-keyvault-lifecycle

Conversation

@GustavoStingelin

@GustavoStingelin GustavoStingelin commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Change Description

This PR implements the keyvault.Vault interface in the DBVault struct backed by db.Store.
Still uses the old salsa20poly1305 implemented by the snacl package.

Steps to Test

make unit pkg=wallet/internal/keyvault/...
make unit-race pkg=wallet/internal/keyvault/...

@coveralls

coveralls commented Jun 18, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 28334091658

Warning

No base build found for commit 90eaacc on sql-wallet.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 54.731%

Details

  • Patch coverage: 68 uncovered changes across 5 files (255 of 323 lines covered, 78.95%).

Uncovered Changes

File Changed Covered %
wallet/internal/keyvault/vault_refreshpassphrase.go 123 82 66.67%
wallet/internal/keyvault/vault_unlock.go 111 94 84.68%
wallet/internal/bwtest/mock/vault.go 5 0 0.0%
wallet/internal/keyvault/vault_encrypt.go 31 28 90.32%
wallet/internal/keyvault/vault.go 17 15 88.24%
Total (8 files) 323 255 78.95%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 54581
Covered Lines: 29873
Line Coverage: 54.73%
Coverage Strength: 17755.71 hits per line

💛 - Coveralls

@GustavoStingelin GustavoStingelin force-pushed the sqldb/impl-keyvault-lifecycle branch 3 times, most recently from 662eceb to 2c9cd5c Compare June 19, 2026 03:27
Comment thread wallet/internal/keyvault/vault_unlock.go
Comment on lines +50 to +52
case waddrmgr.CKTPublic:
return nil, fmt.Errorf("public crypto key: %w",
errUnsupportedCryptoKeyType)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

We will need a compatibility layer to decrypt this type of data from the legacy kvdb backend

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

for kvdb we will just return an error if it's not supported atm.

@GustavoStingelin GustavoStingelin added this to the Introduce SQL store milestone Jun 19, 2026
@GustavoStingelin GustavoStingelin marked this pull request as ready for review June 19, 2026 03:44
Comment thread wallet/internal/keyvault/db_vault_unlock.go Outdated
Comment thread wallet/internal/keyvault/vault_encrypt.go
Comment thread wallet/internal/keyvault/auto_lock_timer.go Outdated
Comment thread wallet/internal/keyvault/auto_lock_timer.go Outdated
Comment thread wallet/internal/keyvault/db_vault_unlock.go Outdated

@yyforyongyu yyforyongyu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Cool very clean commits!

I think the name DBVault implies this is a static db-related struct, while I think we wanna build a vault as a service? The intended architecture is,

  • wallet level: the user facing front, provides all public APIs
  • keyvault: the middle layer, handles locking/unlocking/secret rotation
  • db: the raw layer, provides CRUD

The current keyvault impl seems to be missing that - it's not a pure vault for DB, as it handles timers, and it has an implicit lifecycle there, based on the mutex flow. I think what we want here is a full sub-service that takes over mainLoop (which atm is just a locking service), and handles auto locking - otherwise, we should leave the timer to be handled by the main loop, and this vault should provide only encrypt/decrypt/rotate functionalities.

@GustavoStingelin

Copy link
Copy Markdown
Collaborator Author

@yyforyongyu I responded in the threads.

What name do you think works better for keyvault.DBVault? Maybe keyvault.WalletVault or keyvault.StoreVault? We already have the keyvault.Vault interface, so I think we should avoid making the concrete implementation name too generic.

Also, my next PR will expand the interface to include signing functions. The goal is for the vault to know as little as possible about transactions, while still keeping private keys inside the vault. The design is not done yet, but it will be close to this:

	DerivePubKey(ctx context.Context, ref KeyLocator) (*btcec.PublicKey, error)

	SignECDSA(ctx context.Context, ref KeyLocator,
		digest [32]byte) (*ecdsa.Signature, error)

	SignSchnorr(ctx context.Context, ref KeyLocator,
		digest [32]byte) (*schnorr.Signature, error)

	SignTweakedSchnorr(ctx context.Context, ref KeyLocator,
		digest [32]byte, tweak SchnorrKeyTweak) (*schnorr.Signature, error)

So I think we should also keep that in mind when naming it. The vault will not be only a decrypt and encrypt layer.

What are your thoughts?

@yyforyongyu

Copy link
Copy Markdown
Collaborator

So I think we should also keep that in mind when naming it. The vault will not be only a decrypt and encrypt layer.

Yeah right, i mean all the secret stuff - if we wanna keep it simple, we can remove the auto locker and let the lifecycle of locking/unlocking be handled by wallet. Or we can expand this to be a component, sort like other components, Controller, Syncer etc, tho we haven't peeled them off yet.

What name do you think works better for keyvault.DBVault? Maybe keyvault.WalletVault or keyvault.StoreVault

Naming indeed can be challenging - I think it depends on what we wanna make keyvault do here - if it's pure encrypt/decrypt/signing, DBVault can work(or StoreVault to keep the concepts aligned) as it's now just a thin layer above the DB. If we wanna expand it, can rename it to keyvault.Service? Also don't have good options i guess.

As for the direction - it's up to you to decide, think there are some implementation details from your local branch😺

@GustavoStingelin

Copy link
Copy Markdown
Collaborator Author

I will create a new commit using the channel approach to try it out, but it looks like it will force us to serialize the requests. With the mutex approach, we can still allow parallelism by using sync.RWMutex.

@yyforyongyu

Copy link
Copy Markdown
Collaborator

I will create a new commit using the channel approach to try it out, but it looks like it will force us to serialize the requests. With the mutex approach, we can still allow parallelism by using sync.RWMutex.

Ok let's keep it simple - keep DBVault thin and small, leave the auto locking and lifecycle management to be handled by Controller, and use the mutex to guard simple operations. This means timeout should not be part of keyvault.Vault.Unlock. The timeout belongs to Wallet.UnlockRequest/controller policy, not the passive vault.

@GustavoStingelin

Copy link
Copy Markdown
Collaborator Author

Ok let's keep it simple - keep DBVault thin and small, leave the auto locking and lifecycle management to be handled by Controller, and use the mutex to guard simple operations. This means timeout should not be part of keyvault.Vault.Unlock. The timeout belongs to Wallet.UnlockRequest/controller policy, not the passive vault.

cool, agreed, pushed this one just as a future reference GustavoStingelin@b9670fb#diff-35d82ef33e113def7ecb59fc4ee94faf61459ce701d755589b9673f9794fa0cc

@GustavoStingelin GustavoStingelin force-pushed the sqldb/impl-keyvault-lifecycle branch 3 times, most recently from ccf4548 to c44b2f5 Compare June 28, 2026 16:58
@GustavoStingelin GustavoStingelin force-pushed the sqldb/impl-keyvault-lifecycle branch from c44b2f5 to 1312a0f Compare June 28, 2026 19:32
@GustavoStingelin GustavoStingelin force-pushed the sqldb/impl-keyvault-lifecycle branch from 585bda5 to cec670a Compare June 28, 2026 19:54
@GustavoStingelin

Copy link
Copy Markdown
Collaborator Author

@yyforyongyu ready for review

// Unlock loads wallet secrets from the store and decrypts them into runtime
// state using the provided private passphrase. If the vault is already
// unlocked, Unlock is a no-op and does not validate the passphrase.
func (v *WalletVault) Unlock(ctx context.Context, passphrase []byte) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This unlock path is never wired into the wallet lifecycle: handleUnlockReq still only calls DBUnlock, and handleLockReq only locks addrStore. The concrete vault installed on Wallet therefore keeps unlockedState == nil, so existing wallet paths such as account creation that call w.keyVault.Decrypt(CKTPrivate, ...) will fail with ErrVaultLocked even after a successful wallet unlock. Please either unlock/lock the vault from the controller lifecycle with a Store that can load secrets, or keep the concrete vault out of Wallet until that wiring exists. (gpt-5.5)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can ignore given that this is an intermediate PR.

@yyforyongyu

Copy link
Copy Markdown
Collaborator

No issues found by claude-opus-4-8[1m] 🔐

@yyforyongyu yyforyongyu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Cool very clean now! Left a few comments about the rotation, filenames, typos, and simplification of the zeroing logic.

// RefreshPrivatePassphrase rotates persisted wallet secrets to the provided
// new private passphrase. The vault must already be unlocked when this
// method is called.
RefreshPrivatePassphrase(ctx context.Context, passphrase []byte) error

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Q: so this is now basically ChangePassphrase?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, but I can easy change to ChangePassphrase if u think it is clearer

Comment on lines +50 to +52
case waddrmgr.CKTPublic:
return nil, fmt.Errorf("public crypto key: %w",
errUnsupportedCryptoKeyType)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

for kvdb we will just return an error if it's not supported atm.

package keyvault

// IsLocked reports whether the vault currently has unlocked runtime state.
func (v *WalletVault) IsLocked() bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I notice that the filenames are mimicking the db store filenames, is this intentional? We previously did this on the db store files because a single file can be too large, so we applied the rules only on the store filenames. It's also nice there because each db ops is very independent. Here I would say it's better to move them into the single vault.go as they are closely related.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The split was intentional. I wanted to avoid creating a very large file and make it easier to separate private functions by responsibility. That said, I can join everything into a single file if you still prefer.

}

// TestDBVaultLockWaitsForInFlightUnlock verifies that explicit Lock calls are
// ordered after an Unlock that has already entered the vault lifecycle.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I am not following this test - are we testing the mutex behavior here? seems unnecessary?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, since we have removed autolock, this test no longer makes much sense.

"rotate secrets: %w", v.walletID, err)
}

// validate that the rotated secrets derive the same runtime keys before

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

nit: looks like all the inline comments are not using title case - weird that the agent could also make typos.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I wrote it manually 🤣, will fix the case.


// RefreshPrivatePassphrase rotates persisted wallet secrets to the new private
// passphrase and keeps the existing unlocked runtime state unchanged.
func (v *WalletVault) RefreshPrivatePassphrase(ctx context.Context,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

If WalletVault is meant to stay dead simple/passive, RefreshPrivatePassphrase should probably not be on the Vault interface. It makes the vault own a higher-level workflow:

  • load persisted secrets
  • derive a new master private key from the new passphrase
  • re-encrypt wallet secrets
  • validate them
  • write them back to the store

That feels like controller/store orchestration, not a thin vault primitive.

A cleaner split would be:

  • Controller handles ChangePassphraseRequest.
  • Controller validates policy/state and old passphrase.
  • Store transaction updates persisted wallet secret blobs.
  • Vault is only told to Lock or maybe re-Unlock after the change if needed.

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.

I agree. Additionally, because this method holds v.mtx.Lock(), the same lock used by Encrypt and Decrypt, a slow RefreshPrivatePassphrase will block every Encrypt and Decrypt.

The same can be said for Unlock (i.e., it holds the same lock and performs DB ops), but Unlock is typically called once per session.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

not sure, I will elaborate.

// Unlock loads wallet secrets from the store and decrypts them into runtime
// state using the provided private passphrase. If the vault is already
// unlocked, Unlock is a no-op and does not validate the passphrase.
func (v *WalletVault) Unlock(ctx context.Context, passphrase []byte) error {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can ignore given that this is an intermediate PR.

defer v.mtx.Unlock()

if v.unlockedState != nil {
return nil

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Think this case should be an 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.

It was documented in struct def as:

If Unlock is called while the vault is already unlocked, it must be a no-op and must not validate the provided passphrase.

// including the master HD private key that can spend coins.
state := &unlockedState{}

clearState := true

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This deserves some inline comments to explain why we wanna clear the state, like defensive programming and GC can take time etc.


clearState := true
defer func() {
if clearState {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

looking at this again, think there is a cleaner and simpler version, as we don't need to trace the clearState to understand the flow,

cryptoKeyPrivate, err := decryptCryptoKey(
	&masterPrivateKey, secrets.EncryptedCryptoPrivKey,
)
if err != nil {
	return nil, fmt.Errorf("crypto key private: %w", err)
}
defer cryptoKeyPrivate.Zero()

cryptoKeyScript, err := decryptCryptoKey(
	&masterPrivateKey, secrets.EncryptedCryptoScriptKey,
)
if err != nil {
	return nil, fmt.Errorf("crypto key script: %w", err)
}
defer cryptoKeyScript.Zero()

// ... decrypt/parse hdRootKey ...

state := &unlockedState{
	cryptoKeyPrivate: cryptoKeyPrivate,
	cryptoKeyScript:  cryptoKeyScript,
	hdRootKey:        hdRootKey,
}

// Transfer ownership to state; don't zero locals after this point.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

cool, much simpler this one!

@Abdulkbk Abdulkbk left a comment

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.

Nice work and easy to review!

Left some feedback.

// NewDBVault creates a key-vault bridge scoped to one wallet row.
func NewDBVault(store db.Store, walletID uint32) *DBVault {
return &DBVault{
func NewDBVault(store db.Store, walletID uint32) *WalletVault {

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.

should we also update the constructor's name and callsites to NewWalletVault?

defer v.mtx.Unlock()

if v.unlockedState != nil {
return 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.

It was documented in struct def as:

If Unlock is called while the vault is already unlocked, it must be a no-op and must not validate the provided passphrase.

v.walletID, err)
}

// After a successful unlockedState construction, clear any existing runtime

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.

nit: update comment to reflect that v.clearRuntimeAndLock() is defensive here (or drop) since we already passed through a nil check above (and we held the mtx lock):

if v.unlockedState != nil {
		return nil
}

v.clearRuntimeAndLock() is no-op.

func (v *WalletVault) clearRuntimeAndLock() {
	if v.unlockedState != nil {
		v.unlockedState.zero()
		v.unlockedState = nil
	}
}


// RefreshPrivatePassphrase rotates persisted wallet secrets to the new private
// passphrase and keeps the existing unlocked runtime state unchanged.
func (v *WalletVault) RefreshPrivatePassphrase(ctx context.Context,

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.

I agree. Additionally, because this method holds v.mtx.Lock(), the same lock used by Encrypt and Decrypt, a slow RefreshPrivatePassphrase will block every Encrypt and Decrypt.

The same can be said for Unlock (i.e., it holds the same lock and performs DB ops), but Unlock is typically called once per session.

var encryptedHDRootKey []byte
if v.unlockedState.hdRootKey != nil {
encryptedHDRootKey, err = v.unlockedState.cryptoKeyPrivate.Encrypt(
[]byte(v.unlockedState.hdRootKey.String()),

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.

Might as well add a todo here because this seems to leak priv material too. Like you did for decryptWalletSecrets:

// TODO(gus): wrap with secret.Do from golang 1.26+....

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.

5 participants