From 5060db04fb3adaadc4b019a4de06662f9a4c7923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=CC=81=20A=2EP?= <53834183+Jossec101@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:28:01 +0200 Subject: [PATCH] Add Compromised flag restricting a seed to multisig co-signing only New optional boolean 'Compromised' in the per-fingerprint MF_* config (backward compatible: absent field deserializes to false). When set, the function refuses to sign any input that is not a true multisig requiring at least 2 signatures, so the seed's signature alone can never move funds. The check runs against the input's signable coin (NBitcoin only resolves it when the witness/redeem script is consistent with the UTXO's scriptPubKey) and extracts the multisig threshold from the script code. Intended for retired seeds after an internal wallet rotation: it contains Lambda-mediated misuse (compromised caller draining legacy single-sig wallets); it is not protection against a party holding the seed plaintext. Also extracts GetConfig() so env-var config parsing is shared between the signing loop and DecryptSeed, and documents the flag + the jq flip procedure in the README and seed-ceremony manifest checks. --- README.md | 9 ++++ RemoteSigner.SeedCeremony/Ceremony.cs | 2 +- RemoteSigner.Tests/FunctionTest.cs | 73 ++++++++++++++++++++++---- RemoteSigner.Tests/SeedCeremonyTest.cs | 1 + RemoteSigner/Function.cs | 67 ++++++++++++++++++++--- 5 files changed, 135 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 767019d..c3874a0 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,14 @@ aws lambda update-function-configuration --function-name "$FN" --region "$REGION aws lambda wait function-updated-v2 --function-name "$FN" --region "$REGION" ``` +To later mark a retired seed as compromised (multisig-only co-signing, see the `Compromised` field above), run the same snapshot → merge → apply flow with: + +```bash +jq --arg name MF_xxxxxxxx \ + '{Variables: (.[$name] = (.[$name] | fromjson | .Compromised = true | tojson))}' \ + "env-$FN-$TS.json" > "env-$FN-merged.json" +``` + ### Setting the function main config The lambda function uses environment variables as a key-value dictionary for configuration of the different wallets that can be used to sign, the dictionary keys are the master fingerprints of the different wallets while the value of the keys are the configuration of the lambda function. @@ -134,6 +142,7 @@ The configuration has the following fields: - EncryptedSeedphrase: The encrypted seedphrase as explained above - AwsKmsKeyId: Symmetric key generated by AWS KMS which decrypts the seedphrase +- Compromised (optional, defaults to false): when true, this seed may only co-sign true multisig inputs (threshold >= 2), so its signature alone can never move funds. Use it for retired/rotated seeds once their single-sig (hot) wallets are drained: a compromised NodeGuard host or stolen AWS credentials can then no longer drain legacy single-sig wallets through this function, while multisig spends remain gated by human co-signers. Note this does NOT protect against a party holding the seed plaintext itself. Example (json-like structure of key-value, `ed0210c8` is the master fingerprint of the wallet): diff --git a/RemoteSigner.SeedCeremony/Ceremony.cs b/RemoteSigner.SeedCeremony/Ceremony.cs index 5d0369b..a75c0ae 100644 --- a/RemoteSigner.SeedCeremony/Ceremony.cs +++ b/RemoteSigner.SeedCeremony/Ceremony.cs @@ -56,7 +56,7 @@ public static CeremonyResult Derive(Mnemonic mnemonic, Network network, KeyPath /// /// Builds the MF_* env var value by serializing the lambda's own SignPSBTConfig DTO, so the - /// JSON shape/casing can never drift from what the lambda deserializes + /// JSON shape/casing can never drift from what Function.GetConfig deserializes /// /// /// diff --git a/RemoteSigner.Tests/FunctionTest.cs b/RemoteSigner.Tests/FunctionTest.cs index 7ac9a67..a3388e5 100644 --- a/RemoteSigner.Tests/FunctionTest.cs +++ b/RemoteSigner.Tests/FunctionTest.cs @@ -136,27 +136,80 @@ public async Task FailedSignTest_NoAddedPartialSig() await act.Should().ThrowAsync().WithMessage("Invalid expected number of partial signatures after signing the PSBT, expected: 1, actual: 0"); } - [Fact(Skip = "Requires AWS credentials to call KMS, not available in CI")] - public async Task GenerateEncryptedSeedTest() + //Multisig 2-of-3 P2WSH PSBT (same vector as the first SignTest case) + private const string MultisigPsbt = + "cHNidP8BAF4BAAAAAcbYkt1iwOa6IsI8lrNx1DWQCCg/y7+fQTlfEhDIKOWVAAAAAAD/////AeiN9QUAAAAAIgAgg+aofANl6wKKByTgFl5yBnqUK8f7sn4ULhAAIJb1C0cAAAAATwEENYfPAy8RJCyAAAAB/DvuQjoBjOttImoGYyiO0Pte4PqdeQqzcNAw4Ecw5sgDgI4uHNSCvdBxlpQ8WoEz0WmvhgIra7A4F3FkTsB0RNcQH8zk3jAAAIABAACAAQAAgE8BBDWHzwNWrAP0gAAAAfkIrkpmsP+hqxS1WvDOSPKnAiXLkBCQLWkBr5C5Po+BAlGvFeBbuLfqwYlbP19H/+/s2DIaAu8iKY+J0KIDffBgEGDzoLMwAACAAQAAgAEAAIBPAQQ1h88DfblGjYAAAAH1InDHaHo6+zUe9PG5owwQ87bTkhcGg66pSIwTmhHJmAMiI4UjOOpn+/2Nw1KrJiXnmid2RiEja/HAITCQ00ienxDtAhDIMAAAgAEAAIABAACAAAEBK2SQ9QUAAAAAIgAguNLINpkV//IIFd1ti2ig15+6mPOhNWykV0mwsneO9FciAgMnQqNaMT2Yz47ME+CqhsEMK9fB1sQRGvbBQkPau524BkcwRAIgPcwj6yaA6RZn+4YSHi4S1WE5ziHEt0IZO5KqDE5B0zMCID6cSLumR2AbgwqMTI3/Z3szEyMQauxtzvBpY8Z4oSp8AgEDBAIAAAABBWlSIQMnQqNaMT2Yz47ME+CqhsEMK9fB1sQRGvbBQkPau524BiEDgTQLkhqca3brBTunNmjIsb4WEsFryTwd3BH/ZPS4KkohA91uD9EYRlzIBT6yNU2S2L/wvOA0/em4ocaM//veOtN2U64iBgMnQqNaMT2Yz47ME+CqhsEMK9fB1sQRGvbBQkPau524BhgfzOTeMAAAgAEAAIABAACAAQAAAAAAAAAiBgOBNAuSGpxrdusFO6c2aMixvhYSwWvJPB3cEf9k9LgqShjtAhDIMAAAgAEAAIABAACAAQAAAAAAAAAiBgPdbg/RGEZcyAU+sjVNkti/8LzgNP3puKHGjP/73jrTdhhg86CzMAAAgAEAAIABAACAAQAAAAAAAAAAAA=="; + + //Hot wallet 3-input P2WPKH PSBT (same vector as the third SignTest case) + private const string HotWalletPsbt = + "cHNidP8BAKQBAAAAAwXAGAr1uq/i06r+EW2SjFMKQp3Pg0q+eJcqQ9iWKLrMAAAAAAD/////E9fa5RGuwTHL6xLgYpdDDXz2piFg7F9UWPZXyAZdM8kAAAAAAP////9YszwapNpRRrI7LFJglswjr9SLkao+ywZq/AtjZMDChAAAAAAA/////wGsJgMGAAAAABYAFJO3OqgJq4Mr3qWsDV1YUNj0aDHQAAAAAE8BBDWHzwN9uUaNAAAAAIDetxqi8U7tfzci9EleGtB59Z/A84PlsnvZ229emSEgA6/rPqXCpw3EqihylkpeL/EXKvNGahv+0Dm2JmVJf8VGEO0CEMgwAACAAQAAgAAAAAAAAQEfjGkeAAAAAAAWABQCmza03sKejExNXjBVHR8UyJJWpgEDBAIAAAAiBgIUCFqogmf/kpcaV+42XlzRzx4OWdqxWDesHZkVuK70sBjtAhDIMAAAgAEAAIAAAAAAAAAAAD0AAAAAAQEfyFrXBQAAAAAWABR9cTsoys8smwP2qmjSQM06tKj4fwEDBAIAAAAiBgPzpHxMZtZ1f3rW4L0yyV4gPS45MGMDooXHpvIhAGbvtBjtAhDIMAAAgAEAAIAAAAAAAQAAAC8AAAAAAQEfMGYNAAAAAAAWABRmqBq5qDk2/37GDEq0zM5HXigXjwEDBAIAAAAiBgJr4vl26F2PI9F3JT63vX1qltyDoaAOZ/D212UNJ3u1XhjtAhDIMAAAgAEAAIAAAAAAAQAAADQAAAAAAA=="; + + /// + /// Rewrites the MF_ed0210c8 env var config with Compromised = true (safe: the ctor resets the + /// env var per test and xUnit does not parallelize within a class) + /// + private static void MarkSeedCompromised() + { + var configJson = Environment.GetEnvironmentVariable("MF_ed0210c8"); + var config = JsonSerializer.Deserialize(configJson ?? throw new InvalidOperationException()); + config!.Compromised = true; + Environment.SetEnvironmentVariable("MF_ed0210c8", JsonSerializer.Serialize(config)); + } + + [Fact] + public async Task SignTest_CompromisedSeed_MultisigInputStillSigns() { //Arrange var function = new Function(); - var context = new TestLambdaContext(); + MarkSeedCompromised(); + + var originalPSBT = PSBT.Parse(MultisigPsbt, Network.RegTest); + + Func> GetSeed = (_) => Task.FromResult("middle teach digital prefer fiscal theory syrup enter crash muffin easily anxiety ill barely eagle swim volume consider dynamic unaware deputy middle into physical"); + + //Act + var result = await function.SignPSBT(MultisigPsbt, "Regtest", SigHash.All, GetSeed); + + //Assert + result.Should().NotBeNull(); + var parsedPSBT = PSBT.Parse(result.Psbt ?? throw new InvalidOperationException(), Network.RegTest); + parsedPSBT.Inputs.Sum(x => x.PartialSigs.Count).Should() + .BeGreaterThan(originalPSBT.Inputs.Sum(x => x.PartialSigs.Count)); + } - var mnemonicString = - "middle teach digital prefer fiscal theory syrup enter crash muffin easily anxiety ill barely eagle swim volume consider dynamic unaware deputy middle into physical"; + [Fact] + public async Task SignTest_CompromisedSeed_RefusesSingleSigInput() + { + //Arrange + var function = new Function(); + MarkSeedCompromised(); + + Func> GetSeed = (_) => Task.FromResult("middle teach digital prefer fiscal theory syrup enter crash muffin easily anxiety ill barely eagle swim volume consider dynamic unaware deputy middle into physical"); - var keyId = awsKmsKeyId; //Act - var result = await function.EncryptSeedphrase(mnemonicString, keyId); - var base64Decoding = Convert.FromBase64String(result); + var act = () => function.SignPSBT(HotWalletPsbt, "Regtest", SigHash.All, GetSeed); + //Assert - result.Should().NotBeEmpty(); + await act.Should().ThrowAsync() + .WithMessage("*is marked as compromised, refusing to sign the non-multisig input*"); + } + + [Fact] + public void SignPSBTConfig_CompromisedAbsentFromJson_DefaultsToFalse() + { + //Arrange: an env var value written before the Compromised flag existed + const string legacyConfigJson = "{\"EncryptedSeedphrase\":\"AQIC\",\"AwsKmsKeyId\":\"mrk-123\"}"; - base64Decoding.Should().NotBeEmpty(); + //Act + var config = JsonSerializer.Deserialize(legacyConfigJson); + + //Assert + config.Should().NotBeNull(); + config!.Compromised.Should().BeFalse(); } + [Fact] public async Task ValidateXPub_ValidInputs_NoExceptionThrown() { diff --git a/RemoteSigner.Tests/SeedCeremonyTest.cs b/RemoteSigner.Tests/SeedCeremonyTest.cs index 510c862..588fc64 100644 --- a/RemoteSigner.Tests/SeedCeremonyTest.cs +++ b/RemoteSigner.Tests/SeedCeremonyTest.cs @@ -99,6 +99,7 @@ public void BuildEnvValue_RoundTripsThroughLambdaConfigDeserialization() config.Should().NotBeNull(); config!.EncryptedSeedphrase.Should().Be("AQIC-ciphertext"); config.AwsKmsKeyId.Should().Be("mrk-123"); + config.Compromised.Should().BeFalse(); } [Fact] diff --git a/RemoteSigner/Function.cs b/RemoteSigner/Function.cs index 3a191e1..8af4a26 100644 --- a/RemoteSigner/Function.cs +++ b/RemoteSigner/Function.cs @@ -35,6 +35,15 @@ public class SignPSBTConfig /// AWS KMS Key Id used to decrypt the encrypted seedphrase /// public string AwsKmsKeyId { get; set; } + + /// + /// When true, this seed may only co-sign true multisig inputs (threshold of 2 or more), so its + /// signature alone can never move funds. Meant for retired/rotated seeds: it contains + /// Lambda-mediated misuse (e.g. a compromised caller draining legacy single-sig wallets), it is + /// not a protection against a party holding the seed plaintext. Absent in existing env vars, + /// which deserializes to false. + /// + public bool Compromised { get; set; } } public class Function @@ -116,6 +125,12 @@ public async Task FunctionHandler(APIGatewayHt var inputPSBTMasterFingerPrint = derivationPath.MasterFingerprint; + var config = GetConfig(inputPSBTMasterFingerPrint); + if (config is { Compromised: true }) + { + EnsureCompromisedSeedOnlyCoSignsMultisig(psbtInput, inputPSBTMasterFingerPrint); + } + var seed = await getSeed(derivationPath); if (seed != null) { @@ -186,12 +201,14 @@ public async Task FunctionHandler(APIGatewayHt return null; } - private static async Task DecryptSeed(AmazonKeyManagementServiceClient kmsClient, RootedKeyPath? derivationPath) + /// + /// Reads and deserializes the MF_{fingerprint} env var holding the signing configuration for a + /// master fingerprint. Returns null when no configuration exists for that fingerprint. + /// + /// + public static SignPSBTConfig? GetConfig(HDFingerprint masterFingerprint) { - var inputPSBTMasterFingerPrint = derivationPath.MasterFingerprint; - - var masterFingerPrint = $"MF_{inputPSBTMasterFingerPrint}"; - var configJson = Environment.GetEnvironmentVariable(masterFingerPrint); + var configJson = Environment.GetEnvironmentVariable($"MF_{masterFingerprint}"); if (configJson == null) return null; @@ -200,10 +217,48 @@ public async Task FunctionHandler(APIGatewayHt if (config == null) { var message = "The config could not be deserialized"; - await Console.Error.WriteLineAsync(message); + Console.Error.WriteLine(message); throw new ArgumentException(message, nameof(config)); } + return config; + } + + /// + /// Guard applied to seeds whose config is marked Compromised: the input being signed must be a + /// true multisig (threshold of 2 or more signatures), so this seed's signature alone can never + /// move funds. The script is taken from the input's signable coin, which NBitcoin only resolves + /// when the witness/redeem script is consistent with the UTXO's scriptPubKey. + /// + /// + /// + private static void EnsureCompromisedSeedOnlyCoSignsMultisig(PSBTInput psbtInput, HDFingerprint fingerprint) + { + var signableCoin = psbtInput.GetSignableCoin(out var coinError); + + if (signableCoin == null) + { + throw new ArgumentException( + $"The seed for master fingerprint {fingerprint} is marked as compromised and the signable coin of input {psbtInput.Index} could not be resolved: {coinError}", + nameof(psbtInput)); + } + + var multisigParameters = PayToMultiSigTemplate.Instance.ExtractScriptPubKeyParameters(signableCoin.GetScriptCode()); + + if (multisigParameters == null || multisigParameters.SignatureCount < 2) + { + throw new ArgumentException( + $"The seed for master fingerprint {fingerprint} is marked as compromised, refusing to sign the non-multisig input {psbtInput.Index}; a compromised seed may only co-sign multisig inputs requiring at least 2 signatures", + nameof(psbtInput)); + } + } + + private static async Task DecryptSeed(AmazonKeyManagementServiceClient kmsClient, RootedKeyPath? derivationPath) + { + var config = GetConfig(derivationPath.MasterFingerprint); + + if (config == null) return null; + return await DecryptSeedphrase(kmsClient, config); }