Skip to content

Commit

Permalink
Derive per-user swap-in server keys (#482)
Browse files Browse the repository at this point in the history
Swap-in servers provide an xpub, that can then be used to derive the
public key they'll use for each peer deterministically (based on every
peer's `node_id`).
  • Loading branch information
t-bast authored Jun 15, 2023
1 parent d8f0b49 commit 6618b51
Show file tree
Hide file tree
Showing 9 changed files with 77 additions and 61 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ data class SharedTransaction(
return Transaction(2, inputs, outputs, lockTime)
}

fun sign(keyManager: KeyManager, fundingParams: InteractiveTxParams, localParams: LocalParams): PartiallySignedSharedTransaction {
fun sign(keyManager: KeyManager, fundingParams: InteractiveTxParams, localParams: LocalParams, remoteNodeId: PublicKey): PartiallySignedSharedTransaction {
val unsignedTx = buildUnsignedTx()
val sharedSig = fundingParams.sharedInput?.sign(keyManager.channelKeys(localParams.fundingKeyPath), unsignedTx)
// If we are swapping funds in, we provide our partial signatures to the corresponding inputs.
Expand All @@ -351,7 +351,7 @@ data class SharedTransaction(
remoteInputs
.filterIsInstance<InteractiveTxInput.RemoteSwapIn>()
.find { txIn.outPoint == it.outPoint }
?.let { input -> Transactions.signSwapInputServer(unsignedTx, i, input.txOut, input.userKey, keyManager.swapInOnChainWallet.localServerPrivateKey, keyManager.swapInOnChainWallet.refundDelay) }
?.let { input -> Transactions.signSwapInputServer(unsignedTx, i, input.txOut, input.userKey, keyManager.swapInOnChainWallet.localServerPrivateKey(remoteNodeId), keyManager.swapInOnChainWallet.refundDelay) }
}.filterNotNull()
return PartiallySignedSharedTransaction(this, TxSignatures(fundingParams.channelId, unsignedTx, listOf(), sharedSig, swapUserSigs, swapServerSigs))
}
Expand Down Expand Up @@ -833,7 +833,7 @@ data class InteractiveTxSigningSession(
val commitSig = CommitSig(channelParams.channelId, localSigOfRemoteTx, listOf())
val unsignedLocalCommit = UnsignedLocalCommit(commitmentIndex, firstCommitTx.localSpec, firstCommitTx.localCommitTx, listOf())
val remoteCommit = RemoteCommit(commitmentIndex, firstCommitTx.remoteSpec, firstCommitTx.remoteCommitTx.tx.txid, remotePerCommitmentPoint)
val signedFundingTx = sharedTx.sign(keyManager, fundingParams, channelParams.localParams)
val signedFundingTx = sharedTx.sign(keyManager, fundingParams, channelParams.localParams, channelParams.remoteParams.nodeId)
Pair(InteractiveTxSigningSession(fundingParams, fundingTxIndex, signedFundingTx, Either.Left(unsignedLocalCommit), remoteCommit), commitSig)
}
}
Expand Down
13 changes: 11 additions & 2 deletions src/commonMain/kotlin/fr/acinq/lightning/crypto/KeyManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package fr.acinq.lightning.crypto

import fr.acinq.bitcoin.*
import fr.acinq.bitcoin.DeterministicWallet.hardened
import fr.acinq.bitcoin.io.ByteArrayInput
import fr.acinq.lightning.NodeParams
import fr.acinq.lightning.transactions.Scripts
import fr.acinq.lightning.utils.toByteVector
import fr.acinq.lightning.wire.LightningCodecs

interface KeyManager {

Expand Down Expand Up @@ -114,8 +116,8 @@ interface KeyManager {
val userPrivateKey: PrivateKey = DeterministicWallet.derivePrivateKey(master, swapInKeyBasePath(chain) / hardened(0)).privateKey
val userPublicKey: PublicKey = userPrivateKey.publicKey()

val localServerPrivateKey: PrivateKey = DeterministicWallet.derivePrivateKey(master, swapInKeyBasePath(chain) / hardened(1)).privateKey
val localServerPublicKey: PublicKey = localServerPrivateKey.publicKey()
private val localServerExtendedPrivateKey: DeterministicWallet.ExtendedPrivateKey = DeterministicWallet.derivePrivateKey(master, swapInKeyBasePath(chain) / hardened(1))
fun localServerPrivateKey(remoteNodeId: PublicKey): PrivateKey = DeterministicWallet.derivePrivateKey(localServerExtendedPrivateKey, perUserPath(remoteNodeId)).privateKey

val redeemScript: List<ScriptElt> = Scripts.swapIn2of2(userPublicKey, remoteServerPublicKey, refundDelay)
val pubkeyScript: List<ScriptElt> = Script.pay2wsh(redeemScript)
Expand All @@ -129,6 +131,13 @@ interface KeyManager {
NodeParams.Chain.Regtest, NodeParams.Chain.Testnet -> KeyPath.empty / hardened(51) / hardened(0)
NodeParams.Chain.Mainnet -> KeyPath.empty / hardened(52) / hardened(0)
}

/** Swap-in servers use a different swap-in key for different users. */
fun perUserPath(remoteNodeId: PublicKey): KeyPath {
// We hash the remote node_id and break it into 2-byte values to get non-hardened path indices.
val h = ByteArrayInput(Crypto.sha256(remoteNodeId.value))
return KeyPath((0 until 16).map { _ -> LightningCodecs.u16(h).toLong() })
}
}
}

Expand Down
15 changes: 12 additions & 3 deletions src/commonMain/kotlin/fr/acinq/lightning/crypto/LocalKeyManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ import fr.acinq.lightning.crypto.LocalKeyManager.Companion.channelKeyPath
* ```
*
* @param seed seed from which the channel keys will be derived
* @param remoteSwapInServerKey public key belonging to our swap-in server, that must be used in our swap address
* @param remoteSwapInExtendedPublicKey xpub belonging to our swap-in server, that must be used in our swap address
*/
data class LocalKeyManager(val seed: ByteVector, val chain: Chain, val remoteSwapInServerKey: PublicKey) : KeyManager {
data class LocalKeyManager(val seed: ByteVector, val chain: Chain, val remoteSwapInExtendedPublicKey: String) : KeyManager {

private val master = DeterministicWallet.generate(seed)

Expand All @@ -44,7 +44,16 @@ data class LocalKeyManager(val seed: ByteVector, val chain: Chain, val remoteSwa
)

override val finalOnChainWallet: KeyManager.Bip84OnChainKeys = KeyManager.Bip84OnChainKeys(chain, master, account = 0)
override val swapInOnChainWallet: KeyManager.SwapInOnChainKeys = KeyManager.SwapInOnChainKeys(chain, master, remoteSwapInServerKey)
override val swapInOnChainWallet: KeyManager.SwapInOnChainKeys = run {
val (prefix, xpub) = DeterministicWallet.ExtendedPublicKey.decode(remoteSwapInExtendedPublicKey)
val expectedPrefix = when (chain) {
Chain.Mainnet -> DeterministicWallet.xpub
else -> DeterministicWallet.tpub
}
require(prefix == expectedPrefix) { "unexpected swap-in xpub prefix $prefix (expected $expectedPrefix)" }
val remoteSwapInPublicKey = DeterministicWallet.derivePublicKey(xpub, KeyManager.SwapInOnChainKeys.perUserPath(nodeKeys.nodeKey.publicKey)).publicKey
KeyManager.SwapInOnChainKeys(chain, master, remoteSwapInPublicKey)
}

private val channelKeyBasePath: KeyPath = channelKeyBasePath(chain)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,24 +74,24 @@ class InteractiveTxTestsCommon : LightningTestSuite() {
assertTrue(sharedTxB.sharedTx.localFees < sharedTxA.sharedTx.localFees)

// Bob sends signatures first as he contributed less than Alice.
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB)
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB, f.localParamsA.nodeId)
assertEquals(signedTxB.localSigs.swapInUserSigs.size, 1)
assertEquals(signedTxB.localSigs.swapInServerSigs.size, 3)

// Alice detects invalid signatures from Bob.
val sigsInvalidTxId = signedTxB.localSigs.copy(txHash = randomBytes32())
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsInvalidTxId))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsInvalidTxId))
val sigsMissingUserSigs = signedTxB.localSigs.copy(tlvs = TlvStream(TxSignaturesTlv.SwapInUserSigs(listOf()), TxSignaturesTlv.SwapInServerSigs(signedTxB.localSigs.swapInServerSigs)))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsMissingUserSigs))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsMissingUserSigs))
val sigsMissingServerSigs = signedTxB.localSigs.copy(tlvs = TlvStream(TxSignaturesTlv.SwapInUserSigs(signedTxB.localSigs.swapInUserSigs), TxSignaturesTlv.SwapInServerSigs(listOf())))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsMissingServerSigs))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsMissingServerSigs))
val sigsInvalidUserSig = signedTxB.localSigs.copy(tlvs = TlvStream(TxSignaturesTlv.SwapInUserSigs(listOf(randomBytes64())), TxSignaturesTlv.SwapInServerSigs(signedTxB.localSigs.swapInServerSigs)))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsInvalidUserSig))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsInvalidUserSig))
val sigsInvalidServerSig = signedTxB.localSigs.copy(tlvs = TlvStream(TxSignaturesTlv.SwapInUserSigs(signedTxB.localSigs.swapInUserSigs), TxSignaturesTlv.SwapInServerSigs(signedTxB.localSigs.swapInServerSigs.reversed())))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsInvalidServerSig))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, sigsInvalidServerSig))

// The resulting transaction is valid and has the right feerate.
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
assertNotNull(signedTxA)
assertEquals(signedTxA.localSigs.swapInUserSigs.size, 3)
assertEquals(signedTxA.localSigs.swapInServerSigs.size, 1)
Expand Down Expand Up @@ -146,13 +146,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() {
assertTrue(sharedTxB.sharedTx.localFees < sharedTxA.sharedTx.localFees)

// Alice sends signatures first as she contributed less than Bob.
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA)
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId)
assertNotNull(signedTxA)
assertEquals(signedTxA.localSigs.swapInUserSigs.size, 1)
assertEquals(signedTxA.localSigs.swapInServerSigs.size, 1)

// The resulting transaction is valid and has the right feerate.
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs)
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB, f.localParamsA.nodeId).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs)
assertNotNull(signedTxB)
assertEquals(signedTxB.localSigs.swapInUserSigs.size, 1)
assertEquals(signedTxB.localSigs.swapInServerSigs.size, 1)
Expand Down Expand Up @@ -206,13 +206,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() {
assertTrue(sharedTxA.sharedTx.remoteFees < sharedTxA.sharedTx.localFees)

// Alice contributes more than Bob to the funding output, but Bob's inputs are bigger than Alice's, so Alice must sign first.
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA)
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId)
assertNotNull(signedTxA)
assertEquals(signedTxA.localSigs.swapInUserSigs.size, 1)
assertEquals(signedTxA.localSigs.swapInServerSigs.size, 1)

// The resulting transaction is valid and has the right feerate.
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs)
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB, f.localParamsA.nodeId).addRemoteSigs(f.channelKeysB, f.fundingParamsB, signedTxA.localSigs)
assertNotNull(signedTxB)
Transaction.correctlySpends(signedTxB.signedTx, (sharedTxA.sharedTx.localInputs + sharedTxB.sharedTx.localInputs).map { it.previousTx }, ScriptFlags.STANDARD_SCRIPT_VERIFY_FLAGS)
val feerate = Transactions.fee2rate(signedTxB.tx.fees, signedTxB.signedTx.weight())
Expand Down Expand Up @@ -263,13 +263,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() {
assertEquals(sharedTxB.sharedTx.remoteFees, 2_800_000.msat)

// Bob sends signatures first as he did not contribute at all.
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB)
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB, f.localParamsA.nodeId)
assertNotNull(signedTxB)
assertEquals(signedTxB.localSigs.swapInUserSigs.size, 0)
assertEquals(signedTxB.localSigs.swapInServerSigs.size, 2)

// The resulting transaction is valid and has the right feerate.
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
assertNotNull(signedTxA)
assertEquals(signedTxA.localSigs.swapInUserSigs.size, 2)
assertEquals(signedTxA.localSigs.swapInServerSigs.size, 0)
Expand Down Expand Up @@ -338,14 +338,14 @@ class InteractiveTxTestsCommon : LightningTestSuite() {
assertEquals(sharedTxB.sharedTx.remoteFees, 1_116_000.msat)

// Bob sends signatures first as he contributed less than Alice.
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB)
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB, f.localParamsA.nodeId)
assertNotNull(signedTxB)
assertEquals(signedTxB.localSigs.swapInUserSigs.size, 1)
assertEquals(signedTxB.localSigs.swapInServerSigs.size, 1)
assertNotNull(signedTxB.localSigs.previousFundingTxSig)

// The resulting transaction is valid and has the right feerate.
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
assertNotNull(signedTxA)
assertEquals(signedTxA.localSigs.swapInUserSigs.size, 1)
assertEquals(signedTxA.localSigs.swapInServerSigs.size, 1)
Expand Down Expand Up @@ -412,15 +412,15 @@ class InteractiveTxTestsCommon : LightningTestSuite() {
assertEquals(sharedTxB.sharedTx.remoteFees, 1_000_000.msat)

// Bob sends signatures first as he did not contribute.
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB)
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB, f.localParamsA.nodeId)
assertNotNull(signedTxB)
assertTrue(signedTxB.localSigs.witnesses.isEmpty())
assertTrue(signedTxB.localSigs.swapInUserSigs.isEmpty())
assertTrue(signedTxB.localSigs.swapInServerSigs.isEmpty())
assertNotNull(signedTxB.localSigs.previousFundingTxSig)

// The resulting transaction is valid.
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
assertNotNull(signedTxA)
assertTrue(signedTxA.localSigs.witnesses.isEmpty())
assertTrue(signedTxA.localSigs.swapInUserSigs.isEmpty())
Expand Down Expand Up @@ -492,13 +492,13 @@ class InteractiveTxTestsCommon : LightningTestSuite() {
assertEquals(sharedTxB.sharedTx.remoteFees, 1_000_000.msat)

// Bob sends signatures first as he did not contribute.
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB)
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB, f.localParamsA.nodeId)
assertNotNull(signedTxB)
assertTrue(signedTxB.localSigs.swapInUserSigs.isEmpty())
assertNotNull(signedTxB.localSigs.previousFundingTxSig)

// The resulting transaction is valid.
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
assertNotNull(signedTxA)
assertTrue(signedTxA.localSigs.swapInUserSigs.isEmpty())
assertNotNull(signedTxA.localSigs.previousFundingTxSig)
Expand Down Expand Up @@ -569,14 +569,14 @@ class InteractiveTxTestsCommon : LightningTestSuite() {
assertEquals(sharedTxB.sharedTx.remoteFees, 1_240_000.msat)

// Bob sends signatures first as he did not contribute.
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB)
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB, f.localParamsA.nodeId)
assertNotNull(signedTxB)
assertEquals(signedTxB.localSigs.swapInUserSigs.size, 1)
assertEquals(signedTxB.localSigs.swapInServerSigs.size, 1)
assertNotNull(signedTxB.localSigs.previousFundingTxSig)

// The resulting transaction is valid.
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
val signedTxA = sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs)
assertNotNull(signedTxA)
assertEquals(signedTxA.localSigs.swapInUserSigs.size, 1)
assertEquals(signedTxA.localSigs.swapInServerSigs.size, 1)
Expand Down Expand Up @@ -869,10 +869,10 @@ class InteractiveTxTestsCommon : LightningTestSuite() {
assertNull(sharedTxB.txComplete)

// Alice didn't send her user key, so Bob thinks there aren't any swap inputs
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB)
val signedTxB = sharedTxB.sharedTx.sign(f.keyManagerB, f.fundingParamsB, f.localParamsB, f.localParamsA.nodeId)
assertTrue(signedTxB.localSigs.swapInServerSigs.isEmpty())
// Alice is unable to sign her input since Bob didn't provide his signature.
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs))
assertNull(sharedTxA.sharedTx.sign(f.keyManagerA, f.fundingParamsA, f.localParamsA, f.localParamsB.nodeId).addRemoteSigs(f.channelKeysA, f.fundingParamsA, signedTxB.localSigs))
}

@Test
Expand Down
Loading

0 comments on commit 6618b51

Please sign in to comment.