Skip to content

Commit 2330b74

Browse files
authored
feat(1953): added key generation and import commands (#1959)
Signed-off-by: mmyslblocky <michal.myslinski@blockydevs.com>
1 parent 382b633 commit 2330b74

34 files changed

Lines changed: 1339 additions & 189 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ The Hiero CLI is built on a plugin architecture. The following default plugins a
297297
- **[Token Plugin](src/plugins/token/README.md)** - Create, view, associate, and transfer fungible and non-fungible tokens
298298
- **[Network Plugin](src/plugins/network/README.md)** - Switch networks, manage operator credentials, and check network health
299299
- **[HBAR Plugin](src/plugins/hbar/README.md)** - Transfer HBAR between accounts
300-
- **[Credentials Plugin](src/plugins/credentials/README.md)** - Manage operator credentials and keys
300+
- **[Credentials Plugin](src/plugins/credentials/README.md)** - Generate and import standalone private keys, list and remove stored key credentials
301301
- **[Plugin Management Plugin](src/plugins/plugin-management/README.md)** - Add, remove, enable/disable, and inspect plugins
302302
- **[Topic Plugin](src/plugins/topic/README.md)** - Create topics and manage topic messages
303303
- **[Config Plugin](src/plugins/config/README.md)** - Inspect and update CLI configuration values

skills/hiero-cli/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ State is persisted in `~/.hiero-cli/state/` as JSON files, one per plugin namesp
7979
| `contract-erc721` | ERC-721 contract calls | balanceOf, ownerOf, approve, setApprovalForAll, safeTransferFrom, transferFrom, mint, name, symbol, tokenURI, getApproved, isApprovedForAll. **Requires `contract` plugin** (contract must be deployed first) |
8080
| `network` | Network configuration | list networks, switch network, set/get operator |
8181
| `config` | CLI configuration | list, get, set config options |
82-
| `credentials` | Key/credentials management | list, remove stored credentials |
82+
| `credentials` | Key/credentials management | generate new key, import existing key, list, remove stored credentials (by id or alias) |
8383
| `batch` | Batch transactions | create batch, add transactions, execute, list, delete |
8484
| `swap` | Multi-party asset exchange | create swap, add HBAR/FT/NFT transfers, view, list, execute, delete |
8585
| `eip712` | EIP-712 typed data signing | `hash` compute digest, `sign-ecdsa` / `sign-ed25519` sign payload (accepts pre-computed hash or domain+types+message), `verify-ecdsa` recover signer EVM address, `verify-ed25519` verify Ed25519 signature against a public key |
Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,87 @@
11
# credentials plugin
22

3-
Manage stored credentials (keys) in the KMS: list all key references, remove specific credentials.
3+
Generate, import, list, and remove standalone private keys (key credentials) in the KMS.
44

55
Key references (format: `kr_xxx`) are identifiers for keys stored in the local key manager. They can be used in place of inline `accountId:privateKey` pairs in any command that accepts keys.
66

7+
Key aliases (`--alias`) are human-readable names scoped to the current network that point to a key reference. They are distinct from account aliases — a key alias has no associated Hedera account ID.
8+
9+
---
10+
11+
### `hcli credentials generate`
12+
13+
Generate a new private key in KMS and optionally assign a key alias.
14+
15+
| Option | Short | Type | Required | Description |
16+
| --------------- | ----- | ------ | -------- | --------------------------------------------- |
17+
| `--alias` | `-a` | string | no | Human-readable alias to assign to this key |
18+
| `--key-type` | `-t` | string | no | Key algorithm: `ecdsa` (default) or `ed25519` |
19+
| `--key-manager` | `-k` | string | no | Storage method: `local` or `local_encrypted` |
20+
21+
**Example:**
22+
23+
```
24+
hcli credentials generate
25+
hcli credentials generate --alias my-signing-key --key-type ed25519
26+
```
27+
28+
**Output:** `{ keyRefId, publicKey, keyAlgorithm, keyManager, alias?, network? }`
29+
30+
---
31+
32+
### `hcli credentials import`
33+
34+
Import an existing private key into KMS and optionally assign a key alias.
35+
36+
| Option | Short | Type | Required | Description |
37+
| --------------- | ----- | ------ | -------- | ---------------------------------------------------------------------------------------------- |
38+
| `--key` | `-K` | string | **yes** | Key to import: `{accountId}:{privateKey}`, `{ed25519\|ecdsa}:private:{hex}`, key ref, or alias |
39+
| `--alias` | `-a` | string | no | Alias to assign to this key |
40+
| `--key-manager` | `-k` | string | no | Storage method: `local` or `local_encrypted` (defaults to config setting) |
41+
42+
**Example:**
43+
44+
```
45+
hcli credentials import --key ecdsa:private:abc123... --alias my-key
46+
hcli credentials import --key 0.0.123456:302e... --key-manager local_encrypted
47+
```
48+
49+
**Output:** `{ keyRefId, publicKey, keyManager, alias?, network? }`
50+
751
---
852

953
### `hcli credentials list`
1054

11-
Show all stored credentials and their key reference IDs. No options.
55+
Show all stored credentials and their metadata, including any linked key alias on the current network. No options.
1256

1357
**Example:**
1458

1559
```
1660
hcli credentials list
1761
```
1862

19-
**Output:** Array of `{ keyRefId, accountId?, keyType, keyManager }`
63+
**Output:** `{ credentials: [{ keyRefId, keyManager, publicKey, keyAlgorithm, alias?, labels? }], totalCount }`
2064

2165
---
2266

2367
### `hcli credentials remove`
2468

25-
Remove credentials by key reference ID from KMS storage.
69+
Remove credentials by key reference ID or key alias. Exactly one of `--id` or `--alias` must be provided.
70+
71+
Removing by `--id` also unregisters any key alias on the current network that points to that key, preventing dangling alias references.
2672

27-
⚠️ Requires confirmation. Use `--confirm` to skip.
73+
⚠️ Requires confirmation. Use `--confirm` (`-Y`) to skip.
2874

29-
| Option | Short | Type | Required | Description |
30-
| ------ | ----- | ------ | -------- | --------------------------------------------- |
31-
| `--id` | `-i` | string | **yes** | Key reference ID to remove (e.g. `kr_abc123`) |
75+
| Option | Short | Type | Required | Description |
76+
| --------- | ----- | ------ | ----------- | ------------------------------------------------ |
77+
| `--id` | `-i` | string | one of both | Key reference ID to remove (e.g. `kr_abc123`) |
78+
| `--alias` | `-a` | string | one of both | Key alias to remove (also unregisters the alias) |
3279

3380
**Example:**
3481

3582
```
3683
hcli credentials remove --id kr_abc123 --confirm
84+
hcli credentials remove --alias my-signing-key --confirm
3785
```
3886

39-
**Output:** `{ keyRefId, removed: true }`
87+
**Output:** `{ keyRefId }`

src/__tests__/integration/credentials/credentials.integration.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,6 @@ describe('Credentials Integration Tests', () => {
4444
});
4545
const removeOutput = removeResult.result as CredentialsRemoveOutput;
4646
expect(removeOutput.keyRefId).toBe('test-key');
47-
expect(removeOutput.removed).toBe(true);
4847

4948
const listAfterResult = await credentialsList({
5049
args: {},

src/core/services/key-resolver/__tests__/unit/key-resolver-service.test.ts

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,26 @@ const makeKmsMock = (): jest.Mocked<
7878

7979
const makeAliasMock = (): jest.Mocked<Pick<AliasService, 'resolve'>> => ({
8080
resolve: jest.fn().mockReturnValue({
81+
alias: 'my-alias',
82+
type: AliasType.Account,
83+
network: SupportedNetwork.TESTNET,
8184
entityId: ACCOUNT_ID,
8285
publicKey: PUBLIC_KEY,
8386
keyRefId: KEY_REF_ID,
87+
createdAt: '2024-01-01T00:00:00Z',
8488
}),
8589
});
8690

91+
const makeKeyAliasRecord = (overrides?: Record<string, unknown>) => ({
92+
alias: 'my-key',
93+
type: AliasType.Key,
94+
network: SupportedNetwork.TESTNET,
95+
publicKey: PUBLIC_KEY,
96+
keyRefId: KEY_REF_ID,
97+
createdAt: '2024-01-01T00:00:00Z',
98+
...overrides,
99+
});
100+
87101
const makeNetworkMock = (): jest.Mocked<
88102
Pick<NetworkService, 'getCurrentNetwork' | 'getCurrentOperatorOrThrow'>
89103
> => ({
@@ -173,7 +187,7 @@ describe('resolveAccountCredentials', () => {
173187

174188
expect(alias.resolve).toHaveBeenCalledWith(
175189
'my-alias',
176-
AliasType.Account,
190+
undefined,
177191
SupportedNetwork.TESTNET,
178192
);
179193
expect(result.accountId).toBe(ACCOUNT_ID);
@@ -224,6 +238,86 @@ describe('resolveAccountCredentials', () => {
224238
});
225239
});
226240

241+
// ── resolveAlias (Key aliases) ────────────────────────────────────────────────
242+
243+
describe('resolveAlias - Key aliases', () => {
244+
test('resolves a Key alias to a signing key (publicKey + keyRefId, no accountId)', async () => {
245+
const { service } = makeService({
246+
alias: { resolve: jest.fn().mockReturnValue(makeKeyAliasRecord()) },
247+
});
248+
249+
const result = await service.resolveSigningKey(
250+
{ type: CredentialType.ALIAS, alias: 'my-key', rawValue: 'my-key' },
251+
KEY_MGR,
252+
);
253+
254+
expect(result).toEqual({ keyRefId: KEY_REF_ID, publicKey: PUBLIC_KEY });
255+
});
256+
257+
test('resolves a Key alias via getPublicKey', async () => {
258+
const { service } = makeService({
259+
alias: { resolve: jest.fn().mockReturnValue(makeKeyAliasRecord()) },
260+
});
261+
262+
const result = await service.getPublicKey(
263+
{ type: CredentialType.ALIAS, alias: 'my-key', rawValue: 'my-key' },
264+
KEY_MGR,
265+
);
266+
267+
expect(result).toEqual({ keyRefId: KEY_REF_ID, publicKey: PUBLIC_KEY });
268+
});
269+
270+
test('rejects a Key alias used as a full account credential (no accountId)', async () => {
271+
const { service } = makeService({
272+
alias: { resolve: jest.fn().mockReturnValue(makeKeyAliasRecord()) },
273+
mirror: { getAccounts: jest.fn().mockResolvedValue({ accounts: [] }) },
274+
});
275+
276+
await expect(
277+
service.resolveAccountCredentials(
278+
{ type: CredentialType.ALIAS, alias: 'my-key', rawValue: 'my-key' },
279+
KEY_MGR,
280+
),
281+
).rejects.toThrow(StateError);
282+
});
283+
284+
test('throws StateError when Key alias is missing keyRefId', async () => {
285+
const { service } = makeService({
286+
alias: {
287+
resolve: jest
288+
.fn()
289+
.mockReturnValue(makeKeyAliasRecord({ keyRefId: undefined })),
290+
},
291+
});
292+
293+
await expect(
294+
service.resolveSigningKey(
295+
{ type: CredentialType.ALIAS, alias: 'my-key', rawValue: 'my-key' },
296+
KEY_MGR,
297+
),
298+
).rejects.toThrow(StateError);
299+
});
300+
301+
test('throws ValidationError when alias is neither account nor key (e.g. Token)', async () => {
302+
const { service } = makeService({
303+
alias: {
304+
resolve: jest
305+
.fn()
306+
.mockReturnValue(
307+
makeKeyAliasRecord({ type: AliasType.Token, alias: 'my-token' }),
308+
),
309+
},
310+
});
311+
312+
await expect(
313+
service.resolveSigningKey(
314+
{ type: CredentialType.ALIAS, alias: 'my-token', rawValue: 'my-token' },
315+
KEY_MGR,
316+
),
317+
).rejects.toThrow(ValidationError);
318+
});
319+
});
320+
227321
// ── getPublicKey ──────────────────────────────────────────────────────────────
228322

229323
describe('getPublicKey', () => {

src/core/services/key-resolver/key-resolver-service.ts

Lines changed: 50 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ export class KeyResolverServiceImpl implements KeyResolverService {
217217
case CredentialType.KEY_REFERENCE:
218218
return this.resolveKeyReference(credential);
219219
case CredentialType.ALIAS:
220-
return Promise.resolve(this.resolveAlias(credential));
220+
return this.resolveAlias(credential);
221221
case CredentialType.EVM_ADDRESS:
222222
return this.resolveEvmAddress(credential, keyManager, labels);
223223
}
@@ -467,43 +467,65 @@ export class KeyResolverServiceImpl implements KeyResolverService {
467467
return { accountId, evmAddress, publicKey, keyRefId };
468468
}
469469

470-
private resolveAlias(aliasCredential: AliasCredential): ResolvedKey {
470+
private async resolveAlias(
471+
aliasCredential: AliasCredential,
472+
): Promise<ResolvedKey> {
471473
const currentNetwork = this.network.getCurrentNetwork();
472474

473-
const account = this.alias.resolve(
475+
// Alias names are unique per network regardless of type, so resolve
476+
// without a type expectation and branch on the record's type.
477+
const record = this.alias.resolve(
474478
aliasCredential.alias,
475-
AliasType.Account,
479+
undefined,
476480
currentNetwork,
477481
);
478482

479-
if (!account) {
480-
throw new NotFoundError(
481-
`Account alias not found: ${aliasCredential.alias}`,
482-
{
483-
context: { alias: aliasCredential.alias, network: currentNetwork },
484-
},
485-
);
483+
if (!record) {
484+
throw new NotFoundError(`Alias not found: ${aliasCredential.alias}`, {
485+
context: { alias: aliasCredential.alias, network: currentNetwork },
486+
});
486487
}
487488

488-
if (!account.publicKey || !account.keyRefId || !account.entityId) {
489-
throw new StateError(
490-
'Account alias exists but missing required key data',
491-
{
492-
context: {
493-
alias: aliasCredential.alias,
494-
hasPublicKey: !!account.publicKey,
495-
hasKeyRefId: !!account.keyRefId,
496-
hasEntityId: !!account.entityId,
489+
switch (record.type) {
490+
case AliasType.Account: {
491+
return {
492+
accountId: record.entityId,
493+
publicKey: record.publicKey,
494+
keyRefId: record.keyRefId,
495+
};
496+
}
497+
498+
case AliasType.Key: {
499+
const { accounts } = await this.mirror.getAccounts({
500+
accountPublicKey: record.publicKey,
501+
});
502+
let accountId;
503+
if (accounts.length == 1) {
504+
accountId = accounts[0].accountId;
505+
} else {
506+
this.logger.warn(
507+
`There cannot be one single account ID assigned to key as there are ${accounts.length} results from Hedera Mirror Node`,
508+
);
509+
}
510+
return {
511+
accountId,
512+
publicKey: record.publicKey,
513+
keyRefId: record.keyRefId,
514+
};
515+
}
516+
517+
default:
518+
throw new ValidationError(
519+
`Alias "${aliasCredential.alias}" is not a usable account or key`,
520+
{
521+
context: {
522+
alias: aliasCredential.alias,
523+
type: record.type,
524+
network: currentNetwork,
525+
},
497526
},
498-
},
499-
);
527+
);
500528
}
501-
502-
return {
503-
accountId: account.entityId,
504-
publicKey: account.publicKey,
505-
keyRefId: account.keyRefId,
506-
};
507529
}
508530

509531
public async resolveSigningKeys(

src/core/services/kms/__tests__/unit/kms-service.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,4 +421,31 @@ describe('KmsServiceImpl', () => {
421421
expect.any(Object),
422422
);
423423
});
424+
425+
it('list() includes keyAlgorithm for each credential', () => {
426+
const { service } = setupService();
427+
credentialStorageMockInstance.list.mockReturnValue([
428+
{
429+
keyRefId: 'kr_one',
430+
keyManager: KeyManager.local,
431+
publicKey: 'pub-one',
432+
labels: ['account:create'],
433+
keyAlgorithm: KeyAlgorithm.ECDSA,
434+
createdAt: '2024-01-01T00:00:00Z',
435+
updatedAt: '2024-01-01T00:00:00Z',
436+
},
437+
]);
438+
439+
const result = service.list();
440+
441+
expect(result).toEqual([
442+
{
443+
keyRefId: 'kr_one',
444+
keyManager: KeyManager.local,
445+
publicKey: 'pub-one',
446+
labels: ['account:create'],
447+
keyAlgorithm: KeyAlgorithm.ECDSA,
448+
},
449+
]);
450+
});
424451
});

src/core/services/kms/kms-service.interface.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export interface KmsService {
8888
keyManager: KeyManager;
8989
publicKey: string;
9090
labels?: string[];
91+
keyAlgorithm: KeyAlgorithm;
9192
}>;
9293

9394
/**

0 commit comments

Comments
 (0)