Skip to content

Commit 6c4df76

Browse files
joe-pCopilotmrcointreau
authored
docs: signing examples & secret management docs (#550)
* feat: ed25519 wrapped seed interface * chore: fix npm audit * chore: PR review feedback * test: await runTests * fix: ensure seed is properly zeroed out * feat: wrapped secret * chore: update .nsprc * chore: PR review * chore: remove 64-byte secret wrapping/unwrapping * chore: add signing examples * chore: add mock KMSClient for CI * docs: add secret-management.md * docs: AWS comment * docs: add imports * docs: add header comments * chore: use utils/crypto * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore: rm FIXME from docs/examples * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * docs: fix grammar Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * chore: use sidebar config json * chore: update sidebar config, examples loader and verification scripts * chore: add nsprc exception for flatted vuln (1114526) --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Luca Martini <luca.martini@algorand.foundation>
1 parent a049642 commit 6c4df76

File tree

13 files changed

+2063
-53
lines changed

13 files changed

+2063
-53
lines changed

.nsprc

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,10 @@
4848
"active": true,
4949
"notes": "Dev tooling dependency (npm -> tar)",
5050
"expiry": "2026-03-29"
51+
},
52+
"1114526": {
53+
"active": true,
54+
"notes": "Dev tooling dependency (eslint -> flatted)",
55+
"expiry": "2026-03-29"
5156
}
5257
}

docs/sidebar.config.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
{ "slug": "concepts/core/account" },
1212
{ "slug": "concepts/core/transaction" },
1313
{ "slug": "concepts/core/amount" },
14-
{ "slug": "concepts/core/client" }
14+
{ "slug": "concepts/core/client" },
15+
{ "slug": "concepts/core/secret-management" }
1516
]
1617
},
1718
{
@@ -55,6 +56,7 @@
5556
{ "label": "Common", "slug": "examples/common" },
5657
{ "label": "Indexer Client", "slug": "examples/indexer-client" },
5758
{ "label": "KMD Client", "slug": "examples/kmd-client" },
59+
{ "label": "Signing", "slug": "examples/signing" },
5860
{ "label": "Testing", "slug": "examples/testing" },
5961
{ "label": "Transact", "slug": "examples/transact" }
6062
]
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
---
2+
title: Secret management
3+
description: AlgoKit utils provides interfaces and concrete functions to enable secure management of secret material for signing transactions. This includes support for using an external KMS or key wrapping and unwrapping with a secrets manager.
4+
---
5+
6+
In general, there are three levels of security when it comes to signing transactions with secret material:
7+
8+
1. KMS - The secret material is never exposed to the application
9+
1. Key Wrapping and Unwrapping - The secret material is stored outside of the app (i.e. keychain) and only loaded in memory when signing
10+
1. Plaintext - The secret material is stored in plaintext (i.e. in the environment) and is accessible throughout the runtime of the application
11+
12+
While using plaintext environment variables may be the easier to setup, it is **not recommended** for production use. A compromised environment and/or dependency could lead to the secret material being compromised. Additionally, it is easy to accidentally leak secrets in plaintext through git commits.
13+
14+
The most secure option is to use an external KMS that completely isolates the secret material from the application. KMS', however, can have a high setup cost which may be difficult for a solo developer or small team to manage properly. In this case, the next recommended option is to use key wrapping and unwrapping with a secrets manager. This allows the secret material to be stored securely outside of the application and only loaded in memory when signing is necessary. For example, on a local machine, the OS keyring can be used to store the secret material and only load it when signing transactions.
15+
16+
## Signing with a Wrapped Secret
17+
18+
### Using Keyring Secrets
19+
20+
To read a mnemonic from the OS keyring, you can use the `@napi-rs/keyring` library. This prevents the mnemonic from being stored in
21+
plaintext and ensures it is only loaded in memory when signing.
22+
23+
#### Ed25519 Seed or Mnemonic
24+
25+
When working with a ed25519 seed or mnemonic, you can implement the `WrappedEd25519Seed` interface which allows you to wrap and unwrap the seed as needed. For example, with `@napi-rs/keyring`:
26+
27+
```ts
28+
import { ed25519SigningKeyFromWrappedSecret, WrappedEd25519Seed } from '@algorandfoundation/algokit-utils/crypto'
29+
import { algo, AlgorandClient, microAlgo } from '@algorandfoundation/algokit-utils'
30+
import { mnemonicFromSeed, seedFromMnemonic } from '@algorandfoundation/algokit-utils/algo25'
31+
import { generateAddressWithSigners } from '@algorandfoundation/algokit-utils/transact'
32+
import { Entry } from '@napi-rs/keyring'
33+
34+
const wrappedSeed: WrappedEd25519Seed = {
35+
unwrapEd25519Seed: async () => {
36+
const entry = new Entry('algorand', MNEMONIC_NAME)
37+
const mn = entry.getPassword()
38+
39+
if (!mn) {
40+
throw new Error(`No mnemonic found in keyring for ${MNEMONIC_NAME}`)
41+
}
42+
43+
return seedFromMnemonic(mn)
44+
},
45+
wrapEd25519Seed: async () => {},
46+
}
47+
48+
const signingKey = await ed25519SigningKeyFromWrappedSecret(wrappedSeed)
49+
const algorandAccount = generateAddressWithSigners(signingKey)
50+
51+
const algorand = AlgorandClient.defaultLocalNet()
52+
53+
await algorand.account.ensureFundedFromEnvironment(algorandAccount.addr, algo(1))
54+
55+
const pay = await AlgorandClient.defaultLocalNet().send.payment({
56+
sender: algorandAccount,
57+
receiver: algorandAccount,
58+
amount: microAlgo(0),
59+
})
60+
```
61+
62+
### HD Expanded Secret Key
63+
64+
HD accounts have a 96-byte expanded secret key that can be used in a similar manner to the ed25519 seed, except we need to implement the `WrappedHdExtendedPrivateKey` interface. For example, with `@napi-rs/keyring`:
65+
66+
```ts
67+
import {
68+
ed25519SigningKeyFromWrappedSecret,
69+
peikertXHdWalletGenerator,
70+
WrappedHdExtendedPrivateKey,
71+
} from '@algorandfoundation/algokit-utils/crypto'
72+
import { algo, AlgorandClient, microAlgo } from '@algorandfoundation/algokit-utils'
73+
import { generateAddressWithSigners } from '@algorandfoundation/algokit-utils/transact'
74+
import { Entry } from '@napi-rs/keyring'
75+
76+
const wrappedSeed: WrappedHdExtendedPrivateKey = {
77+
unwrapHdExtendedPrivateKey: async () => {
78+
const entry = new Entry('algorand', SECRET_NAME)
79+
const esk = entry.getSecret()
80+
81+
if (!esk) {
82+
throw new Error(`No mnemonic found in keyring for ${SECRET_NAME}`)
83+
}
84+
85+
// The last 32 bytes of the extended private key is the chain code, which is not needed for signing. This means in most cases you can
86+
// just store the first 64 bytes and then pad the secret to 96 bytes in the unwrap function. If you are storing the full 96 bytes,
87+
// you can just return the secret as is.
88+
if (esk.length === 64) {
89+
const paddedEsk = new Uint8Array(96)
90+
paddedEsk.set(esk, 0)
91+
return paddedEsk
92+
}
93+
94+
return new Uint8Array(esk)
95+
},
96+
wrapHdExtendedPrivateKey: async () => {},
97+
}
98+
99+
const signingKey = await ed25519SigningKeyFromWrappedSecret(wrappedSeed)
100+
const algorandAccount = generateAddressWithSigners(signingKey)
101+
102+
const algorand = AlgorandClient.defaultLocalNet()
103+
104+
await algorand.account.ensureFundedFromEnvironment(algorandAccount.addr, algo(1))
105+
106+
await AlgorandClient.defaultLocalNet().send.payment({
107+
sender: algorandAccount,
108+
receiver: algorandAccount,
109+
amount: microAlgo(0),
110+
})
111+
```
112+
113+
## Signing with a KMS
114+
115+
### Note on KMS Authentication in CI
116+
117+
If you are using a KMS in CI, the best practice for performing signing operations is to use OIDC. For guides for setting up OIDC, refer to the [GitHub documentation](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments).
118+
119+
### Signing with AWS KMS
120+
121+
Using the KMS, you can retrieve the public key and implement `RawEd25519Signer` signer which can then be used to generate an Algorand address and all Algorand-specific signing functions. For example, with AWS:
122+
123+
```ts
124+
import { RawEd25519Signer } from '@algorandfoundation/algokit-utils/crypto'
125+
import { AlgorandClient, microAlgos } from '@algorandfoundation/algokit-utils'
126+
import { generateAddressWithSigners } from '@algorandfoundation/algokit-utils/transact'
127+
import { KMSClient, SignCommand, GetPublicKeyCommand, SignCommandInput, GetPublicKeyCommandInput } from '@aws-sdk/client-kms'
128+
129+
// The following environment variables must be set for this to work:
130+
// - AWS_REGION
131+
// - KEY_ID
132+
// - AWS_ACCESS_KEY_ID
133+
// - AWS_SECRET_ACCESS_KEY
134+
const kms = new KMSClient({ region: process.env.AWS_REGION });
135+
136+
const rawEd25519Signer: RawEd25519Signer = async (data: Uint8Array): Promise<Uint8Array> => {
137+
const resp = await kms.send(
138+
new SignCommand({
139+
KeyId: process.env.KEY_ID,
140+
Message: data,
141+
MessageType: "RAW",
142+
SigningAlgorithm: "ED25519_SHA_512",
143+
})
144+
);
145+
146+
if (!resp.Signature) {
147+
throw new Error("No signature returned from KMS");
148+
}
149+
150+
return resp.Signature;
151+
}
152+
153+
const pubkeyResp = await kms.send(new GetPublicKeyCommand({
154+
KeyId: process.env.KEY_ID,
155+
}));
156+
157+
if (!pubkeyResp.PublicKey) {
158+
throw new Error("No public key returned from KMS");
159+
}
160+
161+
const spki = Buffer.from(pubkeyResp.PublicKey as Uint8Array);
162+
163+
164+
const ed25519SpkiPrefix = Buffer.from([
165+
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00
166+
]);
167+
168+
if (!spki.subarray(0, 12).equals(ed25519SpkiPrefix)) {
169+
throw new Error("Unexpected public key format");
170+
}
171+
172+
const ed25519Pubkey = spki.subarray(12); // 32 bytes
173+
174+
const addrWithSigner = generateAddressWithSigners({ rawEd25519Signer, ed25519Pubkey });
175+
```
176+
177+
## Sharing Secrets and Multisig
178+
179+
It's common for an application to have multiple developers that can deploy changes to mainnet. It may be tempting to share a secret for a single account (manually or through a secrets manager), but this is **not recommended**. Instead, it is recommended to setup a multisig account between all the developers. The multisig account can be a 1/N threshold, which would still allow a single developer to make changes. The benefit of a multisig is that secrets do not need to be shared and all actions are immutably auditable on-chain. Each developer should then follow the practices outlined above.
180+
181+
```ts
182+
const addrWithSigners = generateAddressWithSigners({ rawEd25519Signer: signer, ed25519Pubkey: pubkey });
183+
const msigData: MultisigMetadata = {
184+
version: 1,
185+
threshold: 1,
186+
addrs: [
187+
otherSigner, // Address of the other signer
188+
addrWithSigners.addr
189+
],
190+
}
191+
192+
const algorand = AlgorandClient.defaultLocalNet();
193+
194+
// Create a multisig account that can be used to sign as a 1/N signer
195+
const msigAccount = new MultisigAccount(msigData, [addrWithSigners])
196+
197+
// Send a transaction using the multisig account
198+
const pay = algorand.send.payment({
199+
sender: msigAccount,
200+
amount: microAlgos(0),
201+
receiver: otherSigner,
202+
})
203+
```
204+
205+
## Key Rotation
206+
207+
Algorand has native support for key rotation through a feature called rekeying. Rekeying allows the blockchain address to stay the same while allowing for rotation of the underlying keypair. For example, a common pattern is to have an admin address that can deploy changes to a production contract. Rekeying allows the admin address to remain constant in the contract but allow the secrets used to authorize transactions to rotate. Rekeying can be done with any transaction type, but the simplest is to do a 0 ALGO payment to oneself with the rekeyTo field set.
208+
209+
```ts
210+
const originalAddrWithSigners = generateAddressWithSigners({ rawEd25519Signer: originalSigner, ed25519Pubkey: originalPubkey });
211+
212+
const newAddrWithSigners = generateAddressWithSigners({
213+
rawEd25519Signer: newSigner,
214+
ed25519Pubkey: newPubkey,
215+
// NOTE: We are specifying sendingAddress so we can properly sign transactions on behalf of the original address
216+
sendingAddress: originalAddrWithSigners.addr,
217+
});
218+
219+
220+
const algorand = AlgorandClient.defaultLocalNet();
221+
222+
algorand.send.payment({
223+
sender: originalAddrWithSigners,
224+
amount: microAlgos(0),
225+
receiver: originalAddrWithSigners,
226+
rekeyTo: newAddrWithSigners,
227+
})
228+
```

docs/src/loaders/examples-loader.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ const CATEGORIES: Record<string, CategoryMeta> = {
5757
description: 'Key Management Daemon operations for wallet and key management.',
5858
slug: 'kmd-client',
5959
},
60+
signing: {
61+
label: 'Signing',
62+
description: 'Transaction signing with keyrings and cloud KMS providers.',
63+
slug: 'signing',
64+
},
6065
testing: {
6166
label: 'Testing',
6267
description: 'Testing utilities for mock server setup and Vitest integration.',

examples/README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ Runnable code examples demonstrating every major feature of the `@algorandfounda
44

55
## Overview
66

7-
This folder contains 117 self-contained examples organized into 9 categories. Each example is a standalone TypeScript file that demonstrates specific functionality, progressing from basic to advanced usage within each category.
7+
This folder contains 120 self-contained examples organized into 10 categories. Each example is a standalone TypeScript file that demonstrates specific functionality, progressing from basic to advanced usage within each category.
88

99
## Prerequisites
1010

@@ -181,6 +181,16 @@ Key Management Daemon operations for wallet and key management.
181181
| `12-program-signing.ts` | Create delegated logic signatures |
182182
| `13-multisig-program-signing.ts` | Create delegated multisig logic signatures |
183183

184+
### Signing (`signing/`)
185+
186+
External signing providers and keyring integration for transaction signing.
187+
188+
| File | Description |
189+
| ---------------------------- | ------------------------------------------------------ |
190+
| `01-ed25519-from-keyring.ts` | Retrieve secrets from OS keyring and sign transactions |
191+
| `02-hd-from-keyring.ts` | HD wallet signing using keyring-stored secrets |
192+
| `03-aws-kms.ts` | Ed25519 signing using AWS KMS |
193+
184194
### Testing (`testing/`)
185195

186196
Testing utilities for mock server setup and Vitest integration. No LocalNet required.

0 commit comments

Comments
 (0)