Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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):

Expand Down
2 changes: 1 addition & 1 deletion RemoteSigner.SeedCeremony/Ceremony.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public static CeremonyResult Derive(Mnemonic mnemonic, Network network, KeyPath

/// <summary>
/// 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
/// </summary>
/// <param name="encryptedSeedphraseBase64"></param>
/// <param name="kmsKeyId"></param>
Expand Down
73 changes: 63 additions & 10 deletions RemoteSigner.Tests/FunctionTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@
sigHash,
network);

Func<RootedKeyPath?, Task<string?>> 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");

Check warning on line 54 in RemoteSigner.Tests/FunctionTest.cs

View workflow job for this annotation

GitHub Actions / Run tests

Nullability of reference types in value of type 'Task<string>' doesn't match target type 'Task<string?>'.
//Act
var result = await function.SignPSBT(psbt, network, sigHash, GetSeed);

var parsedPSBT = PSBT.Parse(result.Psbt ?? throw new InvalidOperationException(), Function.ParseNetwork(network));

Check warning on line 58 in RemoteSigner.Tests/FunctionTest.cs

View workflow job for this annotation

GitHub Actions / Run tests

Dereference of a possibly null reference.

//Assert
result.Should().NotBeNull();
Expand Down Expand Up @@ -128,7 +128,7 @@
}
}

Func<RootedKeyPath?, Task<string?>> 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");

Check warning on line 131 in RemoteSigner.Tests/FunctionTest.cs

View workflow job for this annotation

GitHub Actions / Run tests

Nullability of reference types in value of type 'Task<string>' doesn't match target type 'Task<string?>'.
//Act
var act = () => function.SignPSBT(psbt.ToBase64(), "Regtest", SigHash.All, GetSeed);

Expand All @@ -136,27 +136,80 @@
await act.Should().ThrowAsync<ArgumentException>().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==";

/// <summary>
/// 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)
/// </summary>
private static void MarkSeedCompromised()
{
var configJson = Environment.GetEnvironmentVariable("MF_ed0210c8");
var config = JsonSerializer.Deserialize<SignPSBTConfig>(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<RootedKeyPath?, Task<string?>> 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");

Check warning on line 168 in RemoteSigner.Tests/FunctionTest.cs

View workflow job for this annotation

GitHub Actions / Run tests

Nullability of reference types in value of type 'Task<string>' doesn't match target type 'Task<string?>'.

//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);

Check warning on line 175 in RemoteSigner.Tests/FunctionTest.cs

View workflow job for this annotation

GitHub Actions / Run tests

Dereference of a possibly null reference.
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<RootedKeyPath?, Task<string?>> 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<ArgumentException>()
.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<SignPSBTConfig>(legacyConfigJson);

//Assert
config.Should().NotBeNull();
config!.Compromised.Should().BeFalse();
}



[Fact]
public async Task ValidateXPub_ValidInputs_NoExceptionThrown()
{
Expand Down
1 change: 1 addition & 0 deletions RemoteSigner.Tests/SeedCeremonyTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
67 changes: 61 additions & 6 deletions RemoteSigner/Function.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,21 @@
/// <summary>
/// Encrypted seed phrase
/// </summary>
public string EncryptedSeedphrase { get; set; }

Check warning on line 32 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Non-nullable property 'EncryptedSeedphrase' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// AWS KMS Key Id used to decrypt the encrypted seedphrase
/// </summary>
public string AwsKmsKeyId { get; set; }

Check warning on line 37 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Non-nullable property 'AwsKmsKeyId' must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring the property as nullable.

/// <summary>
/// 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.
/// </summary>
public bool Compromised { get; set; }
Comment thread
Jossec101 marked this conversation as resolved.
}

public class Function
Expand Down Expand Up @@ -64,8 +73,8 @@
var kmsClient = new AmazonKeyManagementServiceClient();
#endif

Task<string> GetSeed(RootedKeyPath derivationPath) => DecryptSeed(kmsClient, derivationPath);

Check warning on line 76 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Nullability of reference types in value of type 'Task<string?>' doesn't match target type 'Task<string>'.
var result = await SignPSBT(requestBody.Psbt, requestBody.Network, requestBody.EnforcedSighash, GetSeed);

Check warning on line 77 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Nullability of reference types in return type of 'Task<string> GetSeed(RootedKeyPath derivationPath)' doesn't match the target delegate 'Func<RootedKeyPath?, Task<string?>>' (possibly because of nullability attributes).

response = new APIGatewayHttpApiV2ProxyResponse()
{
Expand Down Expand Up @@ -116,6 +125,12 @@

var inputPSBTMasterFingerPrint = derivationPath.MasterFingerprint;

var config = GetConfig(inputPSBTMasterFingerPrint);
if (config is { Compromised: true })
{
EnsureCompromisedSeedOnlyCoSignsMultisig(psbtInput, inputPSBTMasterFingerPrint);
}

var seed = await getSeed(derivationPath);
if (seed != null)
{
Expand Down Expand Up @@ -186,12 +201,14 @@
return null;
}

private static async Task<string?> DecryptSeed(AmazonKeyManagementServiceClient kmsClient, RootedKeyPath? derivationPath)
/// <summary>
/// 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.
/// </summary>
/// <param name="masterFingerprint"></param>
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;

Expand All @@ -200,10 +217,48 @@
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;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="psbtInput"></param>
/// <param name="fingerprint"></param>
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<string?> DecryptSeed(AmazonKeyManagementServiceClient kmsClient, RootedKeyPath? derivationPath)
{
var config = GetConfig(derivationPath.MasterFingerprint);

Check warning on line 258 in RemoteSigner/Function.cs

View workflow job for this annotation

GitHub Actions / Run tests

Dereference of a possibly null reference.

if (config == null) return null;

return await DecryptSeedphrase(kmsClient, config);
}

Expand Down
Loading