wallet: impl keyvault lifecycle#1273
Conversation
Coverage Report for CI Build 28334091658Warning No base build found for commit Coverage: 54.731%Details
Uncovered Changes
Coverage RegressionsRequires a base build to compare against. How to fix this → Coverage Stats
💛 - Coveralls |
662eceb to
2c9cd5c
Compare
| case waddrmgr.CKTPublic: | ||
| return nil, fmt.Errorf("public crypto key: %w", | ||
| errUnsupportedCryptoKeyType) |
There was a problem hiding this comment.
We will need a compatibility layer to decrypt this type of data from the legacy kvdb backend
There was a problem hiding this comment.
for kvdb we will just return an error if it's not supported atm.
yyforyongyu
left a comment
There was a problem hiding this comment.
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.
|
@yyforyongyu I responded in the threads. What name do you think works better for 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? |
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,
Naming indeed can be challenging - I think it depends on what we wanna make keyvault do here - if it's pure encrypt/decrypt/signing, As for the direction - it's up to you to decide, think there are some implementation details from your local branch😺 |
|
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 |
Ok let's keep it simple - keep |
cool, agreed, pushed this one just as a future reference GustavoStingelin@b9670fb#diff-35d82ef33e113def7ecb59fc4ee94faf61459ce701d755589b9673f9794fa0cc |
ccf4548 to
c44b2f5
Compare
c44b2f5 to
1312a0f
Compare
585bda5 to
cec670a
Compare
|
@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 { |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
Can ignore given that this is an intermediate PR.
|
No issues found by |
yyforyongyu
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
Q: so this is now basically ChangePassphrase?
There was a problem hiding this comment.
Yes, but I can easy change to ChangePassphrase if u think it is clearer
| case waddrmgr.CKTPublic: | ||
| return nil, fmt.Errorf("public crypto key: %w", | ||
| errUnsupportedCryptoKeyType) |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
I am not following this test - are we testing the mutex behavior here? seems unnecessary?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
nit: looks like all the inline comments are not using title case - weird that the agent could also make typos.
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
Can ignore given that this is an intermediate PR.
| defer v.mtx.Unlock() | ||
|
|
||
| if v.unlockedState != nil { | ||
| return nil |
There was a problem hiding this comment.
Think this case should be an error?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
cool, much simpler this one!
Abdulkbk
left a comment
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
should we also update the constructor's name and callsites to NewWalletVault?
| defer v.mtx.Unlock() | ||
|
|
||
| if v.unlockedState != nil { | ||
| return nil |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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()), |
There was a problem hiding this comment.
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+....
Change Description
This PR implements the
keyvault.Vaultinterface in theDBVaultstruct backed bydb.Store.Still uses the old
salsa20poly1305implemented by thesnaclpackage.Steps to Test