@parity/product-sdk-keys 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +40 -6
- package/dist/index.js +22 -2
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/index.ts +2 -1
- package/src/product-account.test.ts +107 -0
- package/src/product-account.ts +62 -0
- package/src/session-key-manager.ts +14 -14
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PolkadotSigner } from 'polkadot-api';
|
|
2
2
|
import { SS58String } from '@parity/product-sdk-address';
|
|
3
|
-
import {
|
|
3
|
+
import { LocalKvStore } from '@parity/product-sdk-local-storage';
|
|
4
4
|
|
|
5
5
|
/** Derivation result for a Substrate/EVM account from seed material. */
|
|
6
6
|
interface DerivedAccount {
|
|
@@ -89,14 +89,14 @@ declare class KeyManager {
|
|
|
89
89
|
/**
|
|
90
90
|
* Manages an sr25519 account derived from a BIP39 mnemonic.
|
|
91
91
|
*
|
|
92
|
-
* @param options.store -
|
|
93
|
-
* Create with `
|
|
92
|
+
* @param options.store - LocalKvStore instance (from `@parity/product-sdk-local-storage`).
|
|
93
|
+
* Create with `createLocalKvStore({ prefix: "session-key" })` for namespaced persistence.
|
|
94
94
|
* @param options.name - Identifies this session key. Defaults to `"default"`.
|
|
95
95
|
* Use different names to manage multiple independent session keys.
|
|
96
96
|
*
|
|
97
97
|
* @example
|
|
98
98
|
* ```ts
|
|
99
|
-
* const store = await
|
|
99
|
+
* const store = await createLocalKvStore({ prefix: "session-key" });
|
|
100
100
|
* const skm = new SessionKeyManager({ store });
|
|
101
101
|
* const key = await skm.getOrCreate();
|
|
102
102
|
* ```
|
|
@@ -105,7 +105,7 @@ declare class SessionKeyManager {
|
|
|
105
105
|
private readonly name;
|
|
106
106
|
private readonly store;
|
|
107
107
|
constructor(options: {
|
|
108
|
-
store:
|
|
108
|
+
store: LocalKvStore;
|
|
109
109
|
name?: string;
|
|
110
110
|
});
|
|
111
111
|
/**
|
|
@@ -145,4 +145,38 @@ declare class SessionKeyManager {
|
|
|
145
145
|
*/
|
|
146
146
|
declare function seedToAccount(mnemonic: string, derivationPath?: string, ss58Prefix?: number, keyType?: "sr25519" | "ed25519"): DerivedAccount;
|
|
147
147
|
|
|
148
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Canonical sr25519 product-account public-key derivation.
|
|
150
|
+
*
|
|
151
|
+
* Mirrored byte-for-byte by polkadot-desktop
|
|
152
|
+
* (`polkadot-desktop/src/domains/product/account/service.ts`) and conceptually
|
|
153
|
+
* by polkadot-app-android-v2
|
|
154
|
+
* (`feature/products/impl/.../ProductAccountDerivationUseCase.kt`).
|
|
155
|
+
*
|
|
156
|
+
* The function works on the parent *public* key alone: sr25519 soft derivation
|
|
157
|
+
* is composable on public keys, so the CLI / web host / any external client can
|
|
158
|
+
* compute the same derived address that the mobile wallet derives privately,
|
|
159
|
+
* without ever seeing the secret key.
|
|
160
|
+
*
|
|
161
|
+
* Junction path: ["product", productId, String(derivationIndex)], applied
|
|
162
|
+
* left-to-right. For each junction, a 32-byte chain code is built:
|
|
163
|
+
* - numeric ("^\d+$") -> SCALE u64 (BigInt), zero-padded to 32 bytes
|
|
164
|
+
* - string -> SCALE str (compact-length + UTF-8), zero-padded
|
|
165
|
+
* - if encoded > 32 bytes -> blake2b256(encoded) (32-byte BLAKE2b digest)
|
|
166
|
+
*
|
|
167
|
+
* # productId constraint (cross-platform parity)
|
|
168
|
+
*
|
|
169
|
+
* `productId` MUST contain at least one non-hex character or be of odd
|
|
170
|
+
* length when serialized as a string. polkadot-app-android-v2's
|
|
171
|
+
* SubstrateJunctionDecoder tries to interpret a junction as hex BEFORE
|
|
172
|
+
* falling through to SCALE-string encoding; polkadot-desktop and this
|
|
173
|
+
* implementation skip that hex branch. For productIds that happen to be
|
|
174
|
+
* even-length all-hex strings (e.g. "deadbeef", "c0ffee01"), Android would
|
|
175
|
+
* derive a different public key than desktop or this implementation. In
|
|
176
|
+
* practice, productIds are always dotNS names (e.g. "playground.dot"),
|
|
177
|
+
* which contain "." and therefore never trip the hex branch on Android.
|
|
178
|
+
*/
|
|
179
|
+
declare function createChainCode(code: string): Uint8Array;
|
|
180
|
+
declare function deriveProductAccountPublicKey(parentPublicKey: Uint8Array, productId: string, derivationIndex: number): Uint8Array;
|
|
181
|
+
|
|
182
|
+
export { type DerivedAccount, type DerivedKeypairs, KeyManager, type SessionKeyInfo, SessionKeyManager, createChainCode, deriveProductAccountPublicKey, seedToAccount };
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { sr25519CreateDerive, ed25519CreateDerive } from '@polkadot-labs/hdkd';
|
|
2
2
|
import { getPolkadotSigner } from 'polkadot-api/signer';
|
|
3
3
|
import { ss58Encode, deriveH160 } from '@parity/product-sdk-address';
|
|
4
|
-
import { deriveKey, nacl } from '@parity/product-sdk-crypto';
|
|
4
|
+
import { deriveKey, nacl, blake2b256 } from '@parity/product-sdk-crypto';
|
|
5
5
|
import { mnemonicToEntropy, entropyToMiniSecret, generateMnemonic } from '@polkadot-labs/hdkd-helpers';
|
|
6
|
+
import { HDKD } from '@scure/sr25519';
|
|
7
|
+
import { u64, str } from 'scale-ts';
|
|
6
8
|
|
|
7
9
|
// src/key-manager.ts
|
|
8
10
|
var DEFAULT_SALT = "product-sdk-keys-v1";
|
|
@@ -170,7 +172,25 @@ var SessionKeyManager = class {
|
|
|
170
172
|
await this.store.remove(this.name);
|
|
171
173
|
}
|
|
172
174
|
};
|
|
175
|
+
var JUNCTION_ID_LEN = 32;
|
|
176
|
+
var NON_NEGATIVE_INTEGER = /^\d+$/;
|
|
177
|
+
function createChainCode(code) {
|
|
178
|
+
const encoded = NON_NEGATIVE_INTEGER.test(code) ? u64.enc(BigInt(code)) : str.enc(code);
|
|
179
|
+
if (encoded.length > JUNCTION_ID_LEN) {
|
|
180
|
+
return blake2b256(encoded);
|
|
181
|
+
}
|
|
182
|
+
const chainCode = new Uint8Array(JUNCTION_ID_LEN);
|
|
183
|
+
chainCode.set(encoded);
|
|
184
|
+
return chainCode;
|
|
185
|
+
}
|
|
186
|
+
function deriveProductAccountPublicKey(parentPublicKey, productId, derivationIndex) {
|
|
187
|
+
const junctions = ["product", productId, String(derivationIndex)];
|
|
188
|
+
return junctions.reduce(
|
|
189
|
+
(pubkey, junction) => HDKD.publicSoft(pubkey, createChainCode(junction)),
|
|
190
|
+
parentPublicKey
|
|
191
|
+
);
|
|
192
|
+
}
|
|
173
193
|
|
|
174
|
-
export { KeyManager, SessionKeyManager, seedToAccount };
|
|
194
|
+
export { KeyManager, SessionKeyManager, createChainCode, deriveProductAccountPublicKey, seedToAccount };
|
|
175
195
|
//# sourceMappingURL=index.js.map
|
|
176
196
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/key-manager.ts","../src/seed-to-account.ts","../src/session-key-manager.ts"],"names":["sr25519CreateDerive","ss58Encode","deriveH160","getPolkadotSigner"],"mappings":"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","file":"index.js","sourcesContent":["import { sr25519CreateDerive } from \"@polkadot-labs/hdkd\";\nimport { getPolkadotSigner } from \"polkadot-api/signer\";\n\nimport { deriveH160, ss58Encode } from \"@parity/product-sdk-address\";\nimport { deriveKey, nacl } from \"@parity/product-sdk-crypto\";\n\nimport type { DerivedAccount, DerivedKeypairs } from \"./types.js\";\n\nconst DEFAULT_SALT = \"product-sdk-keys-v1\";\n\nfunction hexToBytes(hex: string): Uint8Array {\n const clean = hex.startsWith(\"0x\") ? hex.slice(2) : hex;\n const bytes = new Uint8Array(clean.length / 2);\n for (let i = 0; i < bytes.length; i++) {\n bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);\n }\n return bytes;\n}\n\n/**\n * Hierarchical key manager.\n *\n * Holds a 32-byte master key in memory and derives child keys via HKDF-SHA256.\n * Does not persist anything — persistence is the consumer's responsibility.\n */\nexport class KeyManager {\n private readonly masterKey: Uint8Array;\n\n private constructor(masterKey: Uint8Array) {\n this.masterKey = masterKey;\n }\n\n /**\n * Create a KeyManager from a cryptographic signature.\n *\n * Derives master key via HKDF-SHA256:\n * IKM = signatureBytes, salt = options.salt (default \"product-sdk-keys-v1\"), info = signerAddress\n *\n * @param signature - Hex string (with/without 0x prefix) or raw bytes\n * @param signerAddress - SS58 address of the signer\n * @param options.salt - HKDF salt, defaults to \"product-sdk-keys-v1\"\n */\n static fromSignature(\n signature: Uint8Array | string,\n signerAddress: string,\n options?: { salt?: string },\n ): KeyManager {\n const sigBytes =\n signature instanceof Uint8Array\n ? signature\n : hexToBytes(signature.startsWith(\"0x\") ? signature.slice(2) : signature);\n if (sigBytes.length < 32) {\n throw new Error(\n `Signature too short: expected at least 32 bytes, got ${sigBytes.length}`,\n );\n }\n const salt = options?.salt ?? DEFAULT_SALT;\n const masterKey = deriveKey(sigBytes, salt, signerAddress);\n return new KeyManager(masterKey);\n }\n\n /**\n * Create a KeyManager from raw 32-byte key material.\n * For restoring from storage, testing, etc.\n */\n static fromRawKey(masterKey: Uint8Array): KeyManager {\n if (masterKey.length !== 32) {\n throw new Error(`Expected 32-byte master key, got ${masterKey.length} bytes`);\n }\n return new KeyManager(masterKey.slice());\n }\n\n /**\n * Derive a 32-byte symmetric key for a given context string.\n *\n * Uses HKDF-SHA256: IKM=masterKey, salt=\"\", info=context\n */\n deriveSymmetricKey(context: string): Uint8Array {\n return deriveKey(this.masterKey, \"\", context);\n }\n\n /**\n * Derive a Substrate sr25519 account for a given context string.\n *\n * HKDF(masterKey, \"\", \"account:\" + context) → 32-byte seed → sr25519 keypair\n */\n deriveAccount(context: string, ss58Prefix = 42): DerivedAccount {\n const seed = deriveKey(this.masterKey, \"\", `account:${context}`);\n const derive = sr25519CreateDerive(seed);\n const keyPair = derive(\"//0\");\n\n const ss58Address = ss58Encode(keyPair.publicKey, ss58Prefix);\n const h160Address = deriveH160(keyPair.publicKey);\n const signer = getPolkadotSigner(keyPair.publicKey, \"Sr25519\", keyPair.sign);\n\n return { publicKey: keyPair.publicKey, ss58Address, h160Address, signer };\n }\n\n /**\n * Derive NaCl encryption and signing keypairs from the master key.\n *\n * - Encryption: HKDF(masterKey, \"\", \"encryption-keypair\") → nacl.box.keyPair.fromSecretKey\n * - Signing: HKDF(masterKey, \"\", \"signing-keypair\") → nacl.sign.keyPair.fromSeed\n */\n deriveKeypairs(): DerivedKeypairs {\n const encSeed = deriveKey(this.masterKey, \"\", \"encryption-keypair\");\n const encKp = nacl.box.keyPair.fromSecretKey(encSeed);\n\n const sigSeed = deriveKey(this.masterKey, \"\", \"signing-keypair\");\n const sigKp = nacl.sign.keyPair.fromSeed(sigSeed);\n\n return {\n encryption: {\n publicKey: encKp.publicKey,\n secretKey: encKp.secretKey,\n },\n signing: {\n publicKey: sigKp.publicKey,\n secretKey: sigKp.secretKey,\n },\n };\n }\n\n /**\n * Export the raw master key bytes for consumer-managed persistence.\n */\n exportKey(): Uint8Array {\n return this.masterKey.slice();\n }\n}\n\nif (import.meta.vitest) {\n const { test, expect, describe } = import.meta.vitest;\n\n const TEST_SIG = new Uint8Array(64).fill(0xaa);\n const TEST_ADDR = \"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY\";\n\n describe(\"KeyManager.fromSignature\", () => {\n test(\"deterministic master key from fixed signature\", () => {\n const a = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n const b = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n expect(a.exportKey()).toEqual(b.exportKey());\n expect(a.exportKey().length).toBe(32);\n });\n\n test(\"accepts hex string with 0x prefix\", () => {\n const hex = `0x${\"aa\".repeat(64)}`;\n const fromHex = KeyManager.fromSignature(hex, TEST_ADDR);\n const fromBytes = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n expect(fromHex.exportKey()).toEqual(fromBytes.exportKey());\n });\n\n test(\"accepts hex string without 0x prefix\", () => {\n const hex = \"aa\".repeat(64);\n const fromHex = KeyManager.fromSignature(hex, TEST_ADDR);\n const fromBytes = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n expect(fromHex.exportKey()).toEqual(fromBytes.exportKey());\n });\n\n test(\"different addresses produce different master keys\", () => {\n const a = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n const b = KeyManager.fromSignature(\n TEST_SIG,\n \"5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty\",\n );\n expect(a.exportKey()).not.toEqual(b.exportKey());\n });\n\n test(\"custom salt produces different master key\", () => {\n const a = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n const b = KeyManager.fromSignature(TEST_SIG, TEST_ADDR, { salt: \"custom-salt\" });\n expect(a.exportKey()).not.toEqual(b.exportKey());\n });\n\n test(\"rejects empty signature\", () => {\n expect(() => KeyManager.fromSignature(new Uint8Array(0), TEST_ADDR)).toThrow(\n \"Signature too short\",\n );\n });\n\n test(\"rejects short signature\", () => {\n expect(() => KeyManager.fromSignature(new Uint8Array(16), TEST_ADDR)).toThrow(\n \"Signature too short\",\n );\n });\n\n test(\"rejects empty hex string\", () => {\n expect(() => KeyManager.fromSignature(\"0x\", TEST_ADDR)).toThrow(\"Signature too short\");\n });\n });\n\n describe(\"KeyManager.fromRawKey\", () => {\n test(\"accepts 32-byte key\", () => {\n const key = new Uint8Array(32).fill(0xbb);\n const km = KeyManager.fromRawKey(key);\n expect(km.exportKey()).toEqual(key);\n });\n\n test(\"rejects non-32-byte input\", () => {\n expect(() => KeyManager.fromRawKey(new Uint8Array(16))).toThrow(\"Expected 32-byte\");\n expect(() => KeyManager.fromRawKey(new Uint8Array(64))).toThrow(\"Expected 32-byte\");\n });\n\n test(\"exportKey returns a copy\", () => {\n const key = new Uint8Array(32).fill(0xcc);\n const km = KeyManager.fromRawKey(key);\n const exported = km.exportKey();\n exported[0] = 0xff;\n expect(km.exportKey()[0]).toBe(0xcc);\n });\n\n test(\"copies input — mutating original does not affect internal state\", () => {\n const key = new Uint8Array(32).fill(0xaa);\n const km = KeyManager.fromRawKey(key);\n key[0] = 0xff;\n expect(km.exportKey()[0]).toBe(0xaa);\n });\n });\n\n describe(\"deriveSymmetricKey\", () => {\n test(\"deterministic for same context\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xdd));\n const a = km.deriveSymmetricKey(\"doc:123\");\n const b = km.deriveSymmetricKey(\"doc:123\");\n expect(a).toEqual(b);\n expect(a.length).toBe(32);\n });\n\n test(\"different contexts produce different keys\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xdd));\n const a = km.deriveSymmetricKey(\"doc:123\");\n const b = km.deriveSymmetricKey(\"doc:456\");\n expect(a).not.toEqual(b);\n });\n\n test(\"empty context string works\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xdd));\n const key = km.deriveSymmetricKey(\"\");\n expect(key.length).toBe(32);\n });\n\n test(\"deriveSymmetricKey and deriveAccount use different domains\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xdd));\n const symKey = km.deriveSymmetricKey(\"foo\");\n const accountSeed = km.deriveSymmetricKey(\"account:foo\");\n // deriveAccount(\"foo\") uses info=\"account:foo\" internally,\n // so its HKDF output matches deriveSymmetricKey(\"account:foo\")\n // but the final account is further derived through sr25519\n expect(symKey).not.toEqual(accountSeed);\n });\n });\n\n describe(\"deriveAccount\", () => {\n test(\"deterministic for same context\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const a = km.deriveAccount(\"doc-account:123\");\n const b = km.deriveAccount(\"doc-account:123\");\n expect(a.ss58Address).toBe(b.ss58Address);\n expect(a.h160Address).toBe(b.h160Address);\n expect(a.publicKey).toEqual(b.publicKey);\n });\n\n test(\"produces valid addresses\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const account = km.deriveAccount(\"test\");\n expect(account.ss58Address).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/);\n expect(account.h160Address).toMatch(/^0x[a-f0-9]{40}$/);\n expect(account.publicKey.length).toBe(32);\n });\n\n test(\"different contexts produce different accounts\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const a = km.deriveAccount(\"ctx-a\");\n const b = km.deriveAccount(\"ctx-b\");\n expect(a.ss58Address).not.toBe(b.ss58Address);\n });\n\n test(\"custom ss58Prefix changes address encoding\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const generic = km.deriveAccount(\"test\", 42);\n const polkadot = km.deriveAccount(\"test\", 0);\n expect(generic.ss58Address).not.toBe(polkadot.ss58Address);\n expect(generic.publicKey).toEqual(polkadot.publicKey);\n });\n\n test(\"signer has correct publicKey\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const account = km.deriveAccount(\"test\");\n expect(account.signer.publicKey).toEqual(account.publicKey);\n });\n });\n\n describe(\"deriveKeypairs\", () => {\n test(\"deterministic from same master key\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xff));\n const a = km.deriveKeypairs();\n const b = km.deriveKeypairs();\n expect(a.encryption.publicKey).toEqual(b.encryption.publicKey);\n expect(a.signing.publicKey).toEqual(b.signing.publicKey);\n });\n\n test(\"NaCl Box encrypt/decrypt round-trip\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xff));\n const kp = km.deriveKeypairs();\n const message = new TextEncoder().encode(\"hello keys\");\n const nonce = nacl.randomBytes(24);\n const encrypted = nacl.box(\n message,\n nonce,\n kp.encryption.publicKey,\n kp.encryption.secretKey,\n );\n expect(encrypted).not.toBeNull();\n const decrypted = nacl.box.open(\n encrypted!,\n nonce,\n kp.encryption.publicKey,\n kp.encryption.secretKey,\n );\n expect(new TextDecoder().decode(decrypted!)).toBe(\"hello keys\");\n });\n\n test(\"NaCl Box two-party encrypt/decrypt\", () => {\n const kmA = KeyManager.fromRawKey(new Uint8Array(32).fill(0xaa));\n const kmB = KeyManager.fromRawKey(new Uint8Array(32).fill(0xbb));\n const kpA = kmA.deriveKeypairs();\n const kpB = kmB.deriveKeypairs();\n const message = new TextEncoder().encode(\"secret for B\");\n const nonce = nacl.randomBytes(24);\n // A encrypts for B\n const encrypted = nacl.box(\n message,\n nonce,\n kpB.encryption.publicKey,\n kpA.encryption.secretKey,\n );\n expect(encrypted).not.toBeNull();\n // B decrypts from A\n const decrypted = nacl.box.open(\n encrypted!,\n nonce,\n kpA.encryption.publicKey,\n kpB.encryption.secretKey,\n );\n expect(new TextDecoder().decode(decrypted!)).toBe(\"secret for B\");\n });\n\n test(\"NaCl Sign sign/verify round-trip\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xff));\n const kp = km.deriveKeypairs();\n const message = new TextEncoder().encode(\"sign this\");\n const signed = nacl.sign(message, kp.signing.secretKey);\n const opened = nacl.sign.open(signed, kp.signing.publicKey);\n expect(new TextDecoder().decode(opened!)).toBe(\"sign this\");\n });\n });\n}\n","import { ed25519CreateDerive, sr25519CreateDerive } from \"@polkadot-labs/hdkd\";\nimport { entropyToMiniSecret, mnemonicToEntropy } from \"@polkadot-labs/hdkd-helpers\";\nimport { getPolkadotSigner } from \"polkadot-api/signer\";\n\nimport { deriveH160, ss58Encode } from \"@parity/product-sdk-address\";\n\nimport type { DerivedAccount } from \"./types.js\";\n\n/**\n * Derive a DerivedAccount from a BIP39 mnemonic phrase.\n *\n * Uses the specified key type for derivation with a hard derivation path\n * (default `\"//0\"`).\n *\n * @param mnemonic - BIP39 mnemonic phrase\n * @param derivationPath - Hard derivation path, defaults to `\"//0\"`\n * @param ss58Prefix - SS58 network prefix, defaults to 42 (generic)\n * @param keyType - Key type for derivation, either `\"sr25519\"` or `\"ed25519\"`, defaults to `\"sr25519\"`\n */\nexport function seedToAccount(\n mnemonic: string,\n derivationPath = \"//0\",\n ss58Prefix = 42,\n keyType: \"sr25519\" | \"ed25519\" = \"sr25519\",\n): DerivedAccount {\n let entropy: Uint8Array;\n try {\n entropy = mnemonicToEntropy(mnemonic);\n } catch (cause) {\n throw new Error(\"Invalid mnemonic phrase\", { cause });\n }\n const miniSecret = entropyToMiniSecret(entropy);\n const derive =\n keyType === \"ed25519\" ? ed25519CreateDerive(miniSecret) : sr25519CreateDerive(miniSecret);\n const keyPair = derive(derivationPath);\n\n const signerKeyType = keyType === \"ed25519\" ? \"Ed25519\" : \"Sr25519\";\n const ss58Address = ss58Encode(keyPair.publicKey, ss58Prefix);\n const h160Address = deriveH160(keyPair.publicKey);\n const signer = getPolkadotSigner(keyPair.publicKey, signerKeyType, keyPair.sign);\n\n return {\n publicKey: keyPair.publicKey,\n ss58Address,\n h160Address,\n signer,\n };\n}\n\nif (import.meta.vitest) {\n const { test, expect, describe } = import.meta.vitest;\n const { generateMnemonic } = await import(\"@polkadot-labs/hdkd-helpers\");\n\n // Fixed test mnemonic (DO NOT use in production)\n const TEST_MNEMONIC =\n \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\";\n\n describe(\"seedToAccount\", () => {\n test(\"deterministic derivation from fixed mnemonic\", () => {\n const a = seedToAccount(TEST_MNEMONIC);\n const b = seedToAccount(TEST_MNEMONIC);\n expect(a.ss58Address).toBe(b.ss58Address);\n expect(a.h160Address).toBe(b.h160Address);\n expect(a.publicKey).toEqual(b.publicKey);\n expect(a.publicKey.length).toBe(32);\n });\n\n test(\"returns valid SS58 and H160 addresses\", () => {\n const account = seedToAccount(TEST_MNEMONIC);\n expect(account.ss58Address).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/);\n expect(account.h160Address).toMatch(/^0x[a-f0-9]{40}$/);\n });\n\n test(\"custom derivation path produces different addresses\", () => {\n const a = seedToAccount(TEST_MNEMONIC, \"//0\");\n const b = seedToAccount(TEST_MNEMONIC, \"//1\");\n expect(a.ss58Address).not.toBe(b.ss58Address);\n expect(a.h160Address).not.toBe(b.h160Address);\n });\n\n test(\"custom SS58 prefix changes address encoding\", () => {\n const generic = seedToAccount(TEST_MNEMONIC, \"//0\", 42);\n const polkadot = seedToAccount(TEST_MNEMONIC, \"//0\", 0);\n expect(generic.ss58Address).not.toBe(polkadot.ss58Address);\n // Same underlying public key\n expect(generic.publicKey).toEqual(polkadot.publicKey);\n expect(generic.h160Address).toBe(polkadot.h160Address);\n });\n\n test(\"provides a signer\", () => {\n const account = seedToAccount(TEST_MNEMONIC);\n expect(account.signer).toBeDefined();\n expect(account.signer.publicKey).toEqual(account.publicKey);\n });\n\n test(\"works with a freshly generated mnemonic\", () => {\n const mnemonic = generateMnemonic();\n const account = seedToAccount(mnemonic);\n expect(account.ss58Address).toBeTruthy();\n expect(account.publicKey.length).toBe(32);\n });\n\n test(\"throws descriptive error for invalid mnemonic\", () => {\n expect(() => seedToAccount(\"not a valid mnemonic\")).toThrow(\"Invalid mnemonic phrase\");\n });\n\n test(\"throws descriptive error for empty string\", () => {\n expect(() => seedToAccount(\"\")).toThrow(\"Invalid mnemonic phrase\");\n });\n\n test(\"ed25519 derivation produces different address than sr25519\", () => {\n const sr = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"sr25519\");\n const ed = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"ed25519\");\n expect(ed.ss58Address).not.toBe(sr.ss58Address);\n expect(ed.publicKey).not.toEqual(sr.publicKey);\n });\n\n test(\"ed25519 derivation is deterministic\", () => {\n const a = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"ed25519\");\n const b = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"ed25519\");\n expect(a.ss58Address).toBe(b.ss58Address);\n expect(a.h160Address).toBe(b.h160Address);\n expect(a.publicKey).toEqual(b.publicKey);\n expect(a.publicKey.length).toBe(32);\n });\n\n test(\"ed25519 provides a signer with matching publicKey\", () => {\n const account = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"ed25519\");\n expect(account.signer).toBeDefined();\n expect(account.signer.publicKey).toEqual(account.publicKey);\n });\n\n test(\"default keyType is sr25519 (backward compatible)\", () => {\n const withDefault = seedToAccount(TEST_MNEMONIC);\n const withExplicit = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"sr25519\");\n expect(withDefault.ss58Address).toBe(withExplicit.ss58Address);\n expect(withDefault.publicKey).toEqual(withExplicit.publicKey);\n });\n });\n}\n","import { generateMnemonic } from \"@polkadot-labs/hdkd-helpers\";\nimport type { KvStore } from \"@parity/product-sdk-storage\";\n\nimport { seedToAccount } from \"./seed-to-account.js\";\nimport type { SessionKeyInfo } from \"./types.js\";\n\n/**\n * Manages an sr25519 account derived from a BIP39 mnemonic.\n *\n * @param options.store - KvStore instance (from `@parity/product-sdk-storage`).\n * Create with `createKvStore({ prefix: \"session-key\" })` for namespaced persistence.\n * @param options.name - Identifies this session key. Defaults to `\"default\"`.\n * Use different names to manage multiple independent session keys.\n *\n * @example\n * ```ts\n * const store = await createKvStore({ prefix: \"session-key\" });\n * const skm = new SessionKeyManager({ store });\n * const key = await skm.getOrCreate();\n * ```\n */\nexport class SessionKeyManager {\n private readonly name: string;\n private readonly store: KvStore;\n\n constructor(options: { store: KvStore; name?: string }) {\n this.name = options.name ?? \"default\";\n this.store = options.store;\n }\n\n /**\n * Create a new session key from a fresh mnemonic.\n * Persists the mnemonic to the store.\n */\n async create(): Promise<SessionKeyInfo> {\n const mnemonic = generateMnemonic();\n await this.store.set(this.name, mnemonic);\n return { mnemonic, account: seedToAccount(mnemonic) };\n }\n\n /**\n * Load an existing session key from the store.\n * Returns null if no mnemonic is stored.\n */\n async get(): Promise<SessionKeyInfo | null> {\n const mnemonic = await this.store.get(this.name);\n if (!mnemonic) return null;\n return { mnemonic, account: seedToAccount(mnemonic) };\n }\n\n /**\n * Load existing or create a new session key.\n */\n async getOrCreate(): Promise<SessionKeyInfo> {\n const existing = await this.get();\n if (existing) return existing;\n return this.create();\n }\n\n /**\n * Derive a session key from an explicit mnemonic (no storage interaction).\n */\n fromMnemonic(mnemonic: string): SessionKeyInfo {\n return { mnemonic, account: seedToAccount(mnemonic) };\n }\n\n /**\n * Clear the stored mnemonic from the store.\n */\n async clear(): Promise<void> {\n await this.store.remove(this.name);\n }\n}\n\nif (import.meta.vitest) {\n const { test, expect, describe } = import.meta.vitest;\n\n const TEST_MNEMONIC =\n \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\";\n\n function mockKvStore(): KvStore & { data: Map<string, string> } {\n const data = new Map<string, string>();\n return {\n data,\n async get(key) {\n return data.get(key) ?? null;\n },\n async set(key, value) {\n data.set(key, value);\n },\n async remove(key) {\n data.delete(key);\n },\n async getJSON() {\n return null;\n },\n async setJSON() {},\n };\n }\n\n describe(\"SessionKeyManager\", () => {\n test(\"fromMnemonic produces deterministic results\", () => {\n const store = mockKvStore();\n const skm = new SessionKeyManager({ store });\n const a = skm.fromMnemonic(TEST_MNEMONIC);\n const b = skm.fromMnemonic(TEST_MNEMONIC);\n expect(a.mnemonic).toBe(TEST_MNEMONIC);\n expect(a.account.ss58Address).toBe(b.account.ss58Address);\n expect(a.account.h160Address).toBe(b.account.h160Address);\n });\n\n test(\"fromMnemonic throws on invalid mnemonic\", () => {\n const store = mockKvStore();\n const skm = new SessionKeyManager({ store });\n expect(() => skm.fromMnemonic(\"invalid words here\")).toThrow(\"Invalid mnemonic phrase\");\n });\n\n test(\"create and get round-trip\", async () => {\n const store = mockKvStore();\n const skm = new SessionKeyManager({ store });\n const info = await skm.create();\n expect(info.mnemonic).toBeTruthy();\n expect(info.account.ss58Address).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/);\n expect(store.data.get(\"default\")).toBe(info.mnemonic);\n\n const loaded = await skm.get();\n expect(loaded?.mnemonic).toBe(info.mnemonic);\n });\n\n test(\"get returns null when no key stored\", async () => {\n const store = mockKvStore();\n const skm = new SessionKeyManager({ store });\n expect(await skm.get()).toBeNull();\n });\n\n test(\"getOrCreate creates then returns cached\", async () => {\n const store = mockKvStore();\n const skm = new SessionKeyManager({ store });\n const created = await skm.getOrCreate();\n expect(store.data.size).toBe(1);\n\n const loaded = await skm.getOrCreate();\n expect(loaded.mnemonic).toBe(created.mnemonic);\n expect(loaded.account.ss58Address).toBe(created.account.ss58Address);\n });\n\n test(\"clear removes mnemonic from store\", async () => {\n const store = mockKvStore();\n const skm = new SessionKeyManager({ store });\n await skm.create();\n expect(store.data.size).toBe(1);\n\n await skm.clear();\n expect(store.data.size).toBe(0);\n expect(await skm.get()).toBeNull();\n });\n\n test(\"name separates storage keys\", async () => {\n const store = mockKvStore();\n const main = new SessionKeyManager({ name: \"main\", store });\n const burner = new SessionKeyManager({ name: \"burner\", store });\n\n const mainInfo = await main.create();\n const burnerInfo = await burner.create();\n\n expect(store.data.get(\"main\")).toBe(mainInfo.mnemonic);\n expect(store.data.get(\"burner\")).toBe(burnerInfo.mnemonic);\n expect(mainInfo.account.ss58Address).not.toBe(burnerInfo.account.ss58Address);\n\n await main.clear();\n expect(store.data.has(\"main\")).toBe(false);\n expect(store.data.get(\"burner\")).toBe(burnerInfo.mnemonic);\n });\n });\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/key-manager.ts","../src/seed-to-account.ts","../src/session-key-manager.ts","../src/product-account.ts"],"names":["sr25519CreateDerive","ss58Encode","deriveH160","getPolkadotSigner"],"mappings":"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","file":"index.js","sourcesContent":["import { sr25519CreateDerive } from \"@polkadot-labs/hdkd\";\nimport { getPolkadotSigner } from \"polkadot-api/signer\";\n\nimport { deriveH160, ss58Encode } from \"@parity/product-sdk-address\";\nimport { deriveKey, nacl } from \"@parity/product-sdk-crypto\";\n\nimport type { DerivedAccount, DerivedKeypairs } from \"./types.js\";\n\nconst DEFAULT_SALT = \"product-sdk-keys-v1\";\n\nfunction hexToBytes(hex: string): Uint8Array {\n const clean = hex.startsWith(\"0x\") ? hex.slice(2) : hex;\n const bytes = new Uint8Array(clean.length / 2);\n for (let i = 0; i < bytes.length; i++) {\n bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);\n }\n return bytes;\n}\n\n/**\n * Hierarchical key manager.\n *\n * Holds a 32-byte master key in memory and derives child keys via HKDF-SHA256.\n * Does not persist anything — persistence is the consumer's responsibility.\n */\nexport class KeyManager {\n private readonly masterKey: Uint8Array;\n\n private constructor(masterKey: Uint8Array) {\n this.masterKey = masterKey;\n }\n\n /**\n * Create a KeyManager from a cryptographic signature.\n *\n * Derives master key via HKDF-SHA256:\n * IKM = signatureBytes, salt = options.salt (default \"product-sdk-keys-v1\"), info = signerAddress\n *\n * @param signature - Hex string (with/without 0x prefix) or raw bytes\n * @param signerAddress - SS58 address of the signer\n * @param options.salt - HKDF salt, defaults to \"product-sdk-keys-v1\"\n */\n static fromSignature(\n signature: Uint8Array | string,\n signerAddress: string,\n options?: { salt?: string },\n ): KeyManager {\n const sigBytes =\n signature instanceof Uint8Array\n ? signature\n : hexToBytes(signature.startsWith(\"0x\") ? signature.slice(2) : signature);\n if (sigBytes.length < 32) {\n throw new Error(\n `Signature too short: expected at least 32 bytes, got ${sigBytes.length}`,\n );\n }\n const salt = options?.salt ?? DEFAULT_SALT;\n const masterKey = deriveKey(sigBytes, salt, signerAddress);\n return new KeyManager(masterKey);\n }\n\n /**\n * Create a KeyManager from raw 32-byte key material.\n * For restoring from storage, testing, etc.\n */\n static fromRawKey(masterKey: Uint8Array): KeyManager {\n if (masterKey.length !== 32) {\n throw new Error(`Expected 32-byte master key, got ${masterKey.length} bytes`);\n }\n return new KeyManager(masterKey.slice());\n }\n\n /**\n * Derive a 32-byte symmetric key for a given context string.\n *\n * Uses HKDF-SHA256: IKM=masterKey, salt=\"\", info=context\n */\n deriveSymmetricKey(context: string): Uint8Array {\n return deriveKey(this.masterKey, \"\", context);\n }\n\n /**\n * Derive a Substrate sr25519 account for a given context string.\n *\n * HKDF(masterKey, \"\", \"account:\" + context) → 32-byte seed → sr25519 keypair\n */\n deriveAccount(context: string, ss58Prefix = 42): DerivedAccount {\n const seed = deriveKey(this.masterKey, \"\", `account:${context}`);\n const derive = sr25519CreateDerive(seed);\n const keyPair = derive(\"//0\");\n\n const ss58Address = ss58Encode(keyPair.publicKey, ss58Prefix);\n const h160Address = deriveH160(keyPair.publicKey);\n const signer = getPolkadotSigner(keyPair.publicKey, \"Sr25519\", keyPair.sign);\n\n return { publicKey: keyPair.publicKey, ss58Address, h160Address, signer };\n }\n\n /**\n * Derive NaCl encryption and signing keypairs from the master key.\n *\n * - Encryption: HKDF(masterKey, \"\", \"encryption-keypair\") → nacl.box.keyPair.fromSecretKey\n * - Signing: HKDF(masterKey, \"\", \"signing-keypair\") → nacl.sign.keyPair.fromSeed\n */\n deriveKeypairs(): DerivedKeypairs {\n const encSeed = deriveKey(this.masterKey, \"\", \"encryption-keypair\");\n const encKp = nacl.box.keyPair.fromSecretKey(encSeed);\n\n const sigSeed = deriveKey(this.masterKey, \"\", \"signing-keypair\");\n const sigKp = nacl.sign.keyPair.fromSeed(sigSeed);\n\n return {\n encryption: {\n publicKey: encKp.publicKey,\n secretKey: encKp.secretKey,\n },\n signing: {\n publicKey: sigKp.publicKey,\n secretKey: sigKp.secretKey,\n },\n };\n }\n\n /**\n * Export the raw master key bytes for consumer-managed persistence.\n */\n exportKey(): Uint8Array {\n return this.masterKey.slice();\n }\n}\n\nif (import.meta.vitest) {\n const { test, expect, describe } = import.meta.vitest;\n\n const TEST_SIG = new Uint8Array(64).fill(0xaa);\n const TEST_ADDR = \"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY\";\n\n describe(\"KeyManager.fromSignature\", () => {\n test(\"deterministic master key from fixed signature\", () => {\n const a = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n const b = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n expect(a.exportKey()).toEqual(b.exportKey());\n expect(a.exportKey().length).toBe(32);\n });\n\n test(\"accepts hex string with 0x prefix\", () => {\n const hex = `0x${\"aa\".repeat(64)}`;\n const fromHex = KeyManager.fromSignature(hex, TEST_ADDR);\n const fromBytes = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n expect(fromHex.exportKey()).toEqual(fromBytes.exportKey());\n });\n\n test(\"accepts hex string without 0x prefix\", () => {\n const hex = \"aa\".repeat(64);\n const fromHex = KeyManager.fromSignature(hex, TEST_ADDR);\n const fromBytes = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n expect(fromHex.exportKey()).toEqual(fromBytes.exportKey());\n });\n\n test(\"different addresses produce different master keys\", () => {\n const a = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n const b = KeyManager.fromSignature(\n TEST_SIG,\n \"5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty\",\n );\n expect(a.exportKey()).not.toEqual(b.exportKey());\n });\n\n test(\"custom salt produces different master key\", () => {\n const a = KeyManager.fromSignature(TEST_SIG, TEST_ADDR);\n const b = KeyManager.fromSignature(TEST_SIG, TEST_ADDR, { salt: \"custom-salt\" });\n expect(a.exportKey()).not.toEqual(b.exportKey());\n });\n\n test(\"rejects empty signature\", () => {\n expect(() => KeyManager.fromSignature(new Uint8Array(0), TEST_ADDR)).toThrow(\n \"Signature too short\",\n );\n });\n\n test(\"rejects short signature\", () => {\n expect(() => KeyManager.fromSignature(new Uint8Array(16), TEST_ADDR)).toThrow(\n \"Signature too short\",\n );\n });\n\n test(\"rejects empty hex string\", () => {\n expect(() => KeyManager.fromSignature(\"0x\", TEST_ADDR)).toThrow(\"Signature too short\");\n });\n });\n\n describe(\"KeyManager.fromRawKey\", () => {\n test(\"accepts 32-byte key\", () => {\n const key = new Uint8Array(32).fill(0xbb);\n const km = KeyManager.fromRawKey(key);\n expect(km.exportKey()).toEqual(key);\n });\n\n test(\"rejects non-32-byte input\", () => {\n expect(() => KeyManager.fromRawKey(new Uint8Array(16))).toThrow(\"Expected 32-byte\");\n expect(() => KeyManager.fromRawKey(new Uint8Array(64))).toThrow(\"Expected 32-byte\");\n });\n\n test(\"exportKey returns a copy\", () => {\n const key = new Uint8Array(32).fill(0xcc);\n const km = KeyManager.fromRawKey(key);\n const exported = km.exportKey();\n exported[0] = 0xff;\n expect(km.exportKey()[0]).toBe(0xcc);\n });\n\n test(\"copies input — mutating original does not affect internal state\", () => {\n const key = new Uint8Array(32).fill(0xaa);\n const km = KeyManager.fromRawKey(key);\n key[0] = 0xff;\n expect(km.exportKey()[0]).toBe(0xaa);\n });\n });\n\n describe(\"deriveSymmetricKey\", () => {\n test(\"deterministic for same context\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xdd));\n const a = km.deriveSymmetricKey(\"doc:123\");\n const b = km.deriveSymmetricKey(\"doc:123\");\n expect(a).toEqual(b);\n expect(a.length).toBe(32);\n });\n\n test(\"different contexts produce different keys\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xdd));\n const a = km.deriveSymmetricKey(\"doc:123\");\n const b = km.deriveSymmetricKey(\"doc:456\");\n expect(a).not.toEqual(b);\n });\n\n test(\"empty context string works\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xdd));\n const key = km.deriveSymmetricKey(\"\");\n expect(key.length).toBe(32);\n });\n\n test(\"deriveSymmetricKey and deriveAccount use different domains\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xdd));\n const symKey = km.deriveSymmetricKey(\"foo\");\n const accountSeed = km.deriveSymmetricKey(\"account:foo\");\n // deriveAccount(\"foo\") uses info=\"account:foo\" internally,\n // so its HKDF output matches deriveSymmetricKey(\"account:foo\")\n // but the final account is further derived through sr25519\n expect(symKey).not.toEqual(accountSeed);\n });\n });\n\n describe(\"deriveAccount\", () => {\n test(\"deterministic for same context\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const a = km.deriveAccount(\"doc-account:123\");\n const b = km.deriveAccount(\"doc-account:123\");\n expect(a.ss58Address).toBe(b.ss58Address);\n expect(a.h160Address).toBe(b.h160Address);\n expect(a.publicKey).toEqual(b.publicKey);\n });\n\n test(\"produces valid addresses\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const account = km.deriveAccount(\"test\");\n expect(account.ss58Address).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/);\n expect(account.h160Address).toMatch(/^0x[a-f0-9]{40}$/);\n expect(account.publicKey.length).toBe(32);\n });\n\n test(\"different contexts produce different accounts\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const a = km.deriveAccount(\"ctx-a\");\n const b = km.deriveAccount(\"ctx-b\");\n expect(a.ss58Address).not.toBe(b.ss58Address);\n });\n\n test(\"custom ss58Prefix changes address encoding\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const generic = km.deriveAccount(\"test\", 42);\n const polkadot = km.deriveAccount(\"test\", 0);\n expect(generic.ss58Address).not.toBe(polkadot.ss58Address);\n expect(generic.publicKey).toEqual(polkadot.publicKey);\n });\n\n test(\"signer has correct publicKey\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xee));\n const account = km.deriveAccount(\"test\");\n expect(account.signer.publicKey).toEqual(account.publicKey);\n });\n });\n\n describe(\"deriveKeypairs\", () => {\n test(\"deterministic from same master key\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xff));\n const a = km.deriveKeypairs();\n const b = km.deriveKeypairs();\n expect(a.encryption.publicKey).toEqual(b.encryption.publicKey);\n expect(a.signing.publicKey).toEqual(b.signing.publicKey);\n });\n\n test(\"NaCl Box encrypt/decrypt round-trip\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xff));\n const kp = km.deriveKeypairs();\n const message = new TextEncoder().encode(\"hello keys\");\n const nonce = nacl.randomBytes(24);\n const encrypted = nacl.box(\n message,\n nonce,\n kp.encryption.publicKey,\n kp.encryption.secretKey,\n );\n expect(encrypted).not.toBeNull();\n const decrypted = nacl.box.open(\n encrypted!,\n nonce,\n kp.encryption.publicKey,\n kp.encryption.secretKey,\n );\n expect(new TextDecoder().decode(decrypted!)).toBe(\"hello keys\");\n });\n\n test(\"NaCl Box two-party encrypt/decrypt\", () => {\n const kmA = KeyManager.fromRawKey(new Uint8Array(32).fill(0xaa));\n const kmB = KeyManager.fromRawKey(new Uint8Array(32).fill(0xbb));\n const kpA = kmA.deriveKeypairs();\n const kpB = kmB.deriveKeypairs();\n const message = new TextEncoder().encode(\"secret for B\");\n const nonce = nacl.randomBytes(24);\n // A encrypts for B\n const encrypted = nacl.box(\n message,\n nonce,\n kpB.encryption.publicKey,\n kpA.encryption.secretKey,\n );\n expect(encrypted).not.toBeNull();\n // B decrypts from A\n const decrypted = nacl.box.open(\n encrypted!,\n nonce,\n kpA.encryption.publicKey,\n kpB.encryption.secretKey,\n );\n expect(new TextDecoder().decode(decrypted!)).toBe(\"secret for B\");\n });\n\n test(\"NaCl Sign sign/verify round-trip\", () => {\n const km = KeyManager.fromRawKey(new Uint8Array(32).fill(0xff));\n const kp = km.deriveKeypairs();\n const message = new TextEncoder().encode(\"sign this\");\n const signed = nacl.sign(message, kp.signing.secretKey);\n const opened = nacl.sign.open(signed, kp.signing.publicKey);\n expect(new TextDecoder().decode(opened!)).toBe(\"sign this\");\n });\n });\n}\n","import { ed25519CreateDerive, sr25519CreateDerive } from \"@polkadot-labs/hdkd\";\nimport { entropyToMiniSecret, mnemonicToEntropy } from \"@polkadot-labs/hdkd-helpers\";\nimport { getPolkadotSigner } from \"polkadot-api/signer\";\n\nimport { deriveH160, ss58Encode } from \"@parity/product-sdk-address\";\n\nimport type { DerivedAccount } from \"./types.js\";\n\n/**\n * Derive a DerivedAccount from a BIP39 mnemonic phrase.\n *\n * Uses the specified key type for derivation with a hard derivation path\n * (default `\"//0\"`).\n *\n * @param mnemonic - BIP39 mnemonic phrase\n * @param derivationPath - Hard derivation path, defaults to `\"//0\"`\n * @param ss58Prefix - SS58 network prefix, defaults to 42 (generic)\n * @param keyType - Key type for derivation, either `\"sr25519\"` or `\"ed25519\"`, defaults to `\"sr25519\"`\n */\nexport function seedToAccount(\n mnemonic: string,\n derivationPath = \"//0\",\n ss58Prefix = 42,\n keyType: \"sr25519\" | \"ed25519\" = \"sr25519\",\n): DerivedAccount {\n let entropy: Uint8Array;\n try {\n entropy = mnemonicToEntropy(mnemonic);\n } catch (cause) {\n throw new Error(\"Invalid mnemonic phrase\", { cause });\n }\n const miniSecret = entropyToMiniSecret(entropy);\n const derive =\n keyType === \"ed25519\" ? ed25519CreateDerive(miniSecret) : sr25519CreateDerive(miniSecret);\n const keyPair = derive(derivationPath);\n\n const signerKeyType = keyType === \"ed25519\" ? \"Ed25519\" : \"Sr25519\";\n const ss58Address = ss58Encode(keyPair.publicKey, ss58Prefix);\n const h160Address = deriveH160(keyPair.publicKey);\n const signer = getPolkadotSigner(keyPair.publicKey, signerKeyType, keyPair.sign);\n\n return {\n publicKey: keyPair.publicKey,\n ss58Address,\n h160Address,\n signer,\n };\n}\n\nif (import.meta.vitest) {\n const { test, expect, describe } = import.meta.vitest;\n const { generateMnemonic } = await import(\"@polkadot-labs/hdkd-helpers\");\n\n // Fixed test mnemonic (DO NOT use in production)\n const TEST_MNEMONIC =\n \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\";\n\n describe(\"seedToAccount\", () => {\n test(\"deterministic derivation from fixed mnemonic\", () => {\n const a = seedToAccount(TEST_MNEMONIC);\n const b = seedToAccount(TEST_MNEMONIC);\n expect(a.ss58Address).toBe(b.ss58Address);\n expect(a.h160Address).toBe(b.h160Address);\n expect(a.publicKey).toEqual(b.publicKey);\n expect(a.publicKey.length).toBe(32);\n });\n\n test(\"returns valid SS58 and H160 addresses\", () => {\n const account = seedToAccount(TEST_MNEMONIC);\n expect(account.ss58Address).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/);\n expect(account.h160Address).toMatch(/^0x[a-f0-9]{40}$/);\n });\n\n test(\"custom derivation path produces different addresses\", () => {\n const a = seedToAccount(TEST_MNEMONIC, \"//0\");\n const b = seedToAccount(TEST_MNEMONIC, \"//1\");\n expect(a.ss58Address).not.toBe(b.ss58Address);\n expect(a.h160Address).not.toBe(b.h160Address);\n });\n\n test(\"custom SS58 prefix changes address encoding\", () => {\n const generic = seedToAccount(TEST_MNEMONIC, \"//0\", 42);\n const polkadot = seedToAccount(TEST_MNEMONIC, \"//0\", 0);\n expect(generic.ss58Address).not.toBe(polkadot.ss58Address);\n // Same underlying public key\n expect(generic.publicKey).toEqual(polkadot.publicKey);\n expect(generic.h160Address).toBe(polkadot.h160Address);\n });\n\n test(\"provides a signer\", () => {\n const account = seedToAccount(TEST_MNEMONIC);\n expect(account.signer).toBeDefined();\n expect(account.signer.publicKey).toEqual(account.publicKey);\n });\n\n test(\"works with a freshly generated mnemonic\", () => {\n const mnemonic = generateMnemonic();\n const account = seedToAccount(mnemonic);\n expect(account.ss58Address).toBeTruthy();\n expect(account.publicKey.length).toBe(32);\n });\n\n test(\"throws descriptive error for invalid mnemonic\", () => {\n expect(() => seedToAccount(\"not a valid mnemonic\")).toThrow(\"Invalid mnemonic phrase\");\n });\n\n test(\"throws descriptive error for empty string\", () => {\n expect(() => seedToAccount(\"\")).toThrow(\"Invalid mnemonic phrase\");\n });\n\n test(\"ed25519 derivation produces different address than sr25519\", () => {\n const sr = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"sr25519\");\n const ed = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"ed25519\");\n expect(ed.ss58Address).not.toBe(sr.ss58Address);\n expect(ed.publicKey).not.toEqual(sr.publicKey);\n });\n\n test(\"ed25519 derivation is deterministic\", () => {\n const a = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"ed25519\");\n const b = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"ed25519\");\n expect(a.ss58Address).toBe(b.ss58Address);\n expect(a.h160Address).toBe(b.h160Address);\n expect(a.publicKey).toEqual(b.publicKey);\n expect(a.publicKey.length).toBe(32);\n });\n\n test(\"ed25519 provides a signer with matching publicKey\", () => {\n const account = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"ed25519\");\n expect(account.signer).toBeDefined();\n expect(account.signer.publicKey).toEqual(account.publicKey);\n });\n\n test(\"default keyType is sr25519 (backward compatible)\", () => {\n const withDefault = seedToAccount(TEST_MNEMONIC);\n const withExplicit = seedToAccount(TEST_MNEMONIC, \"//0\", 42, \"sr25519\");\n expect(withDefault.ss58Address).toBe(withExplicit.ss58Address);\n expect(withDefault.publicKey).toEqual(withExplicit.publicKey);\n });\n });\n}\n","import { generateMnemonic } from \"@polkadot-labs/hdkd-helpers\";\nimport type { LocalKvStore } from \"@parity/product-sdk-local-storage\";\n\nimport { seedToAccount } from \"./seed-to-account.js\";\nimport type { SessionKeyInfo } from \"./types.js\";\n\n/**\n * Manages an sr25519 account derived from a BIP39 mnemonic.\n *\n * @param options.store - LocalKvStore instance (from `@parity/product-sdk-local-storage`).\n * Create with `createLocalKvStore({ prefix: \"session-key\" })` for namespaced persistence.\n * @param options.name - Identifies this session key. Defaults to `\"default\"`.\n * Use different names to manage multiple independent session keys.\n *\n * @example\n * ```ts\n * const store = await createLocalKvStore({ prefix: \"session-key\" });\n * const skm = new SessionKeyManager({ store });\n * const key = await skm.getOrCreate();\n * ```\n */\nexport class SessionKeyManager {\n private readonly name: string;\n private readonly store: LocalKvStore;\n\n constructor(options: { store: LocalKvStore; name?: string }) {\n this.name = options.name ?? \"default\";\n this.store = options.store;\n }\n\n /**\n * Create a new session key from a fresh mnemonic.\n * Persists the mnemonic to the store.\n */\n async create(): Promise<SessionKeyInfo> {\n const mnemonic = generateMnemonic();\n await this.store.set(this.name, mnemonic);\n return { mnemonic, account: seedToAccount(mnemonic) };\n }\n\n /**\n * Load an existing session key from the store.\n * Returns null if no mnemonic is stored.\n */\n async get(): Promise<SessionKeyInfo | null> {\n const mnemonic = await this.store.get(this.name);\n if (!mnemonic) return null;\n return { mnemonic, account: seedToAccount(mnemonic) };\n }\n\n /**\n * Load existing or create a new session key.\n */\n async getOrCreate(): Promise<SessionKeyInfo> {\n const existing = await this.get();\n if (existing) return existing;\n return this.create();\n }\n\n /**\n * Derive a session key from an explicit mnemonic (no storage interaction).\n */\n fromMnemonic(mnemonic: string): SessionKeyInfo {\n return { mnemonic, account: seedToAccount(mnemonic) };\n }\n\n /**\n * Clear the stored mnemonic from the store.\n */\n async clear(): Promise<void> {\n await this.store.remove(this.name);\n }\n}\n\nif (import.meta.vitest) {\n const { test, expect, describe } = import.meta.vitest;\n\n const TEST_MNEMONIC =\n \"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\";\n\n function mockLocalKvStore(): LocalKvStore & { data: Map<string, string> } {\n const data = new Map<string, string>();\n return {\n data,\n async get(key) {\n return data.get(key) ?? null;\n },\n async set(key, value) {\n data.set(key, value);\n },\n async remove(key) {\n data.delete(key);\n },\n async getJSON() {\n return null;\n },\n async setJSON() {},\n };\n }\n\n describe(\"SessionKeyManager\", () => {\n test(\"fromMnemonic produces deterministic results\", () => {\n const store = mockLocalKvStore();\n const skm = new SessionKeyManager({ store });\n const a = skm.fromMnemonic(TEST_MNEMONIC);\n const b = skm.fromMnemonic(TEST_MNEMONIC);\n expect(a.mnemonic).toBe(TEST_MNEMONIC);\n expect(a.account.ss58Address).toBe(b.account.ss58Address);\n expect(a.account.h160Address).toBe(b.account.h160Address);\n });\n\n test(\"fromMnemonic throws on invalid mnemonic\", () => {\n const store = mockLocalKvStore();\n const skm = new SessionKeyManager({ store });\n expect(() => skm.fromMnemonic(\"invalid words here\")).toThrow(\"Invalid mnemonic phrase\");\n });\n\n test(\"create and get round-trip\", async () => {\n const store = mockLocalKvStore();\n const skm = new SessionKeyManager({ store });\n const info = await skm.create();\n expect(info.mnemonic).toBeTruthy();\n expect(info.account.ss58Address).toMatch(/^[1-9A-HJ-NP-Za-km-z]+$/);\n expect(store.data.get(\"default\")).toBe(info.mnemonic);\n\n const loaded = await skm.get();\n expect(loaded?.mnemonic).toBe(info.mnemonic);\n });\n\n test(\"get returns null when no key stored\", async () => {\n const store = mockLocalKvStore();\n const skm = new SessionKeyManager({ store });\n expect(await skm.get()).toBeNull();\n });\n\n test(\"getOrCreate creates then returns cached\", async () => {\n const store = mockLocalKvStore();\n const skm = new SessionKeyManager({ store });\n const created = await skm.getOrCreate();\n expect(store.data.size).toBe(1);\n\n const loaded = await skm.getOrCreate();\n expect(loaded.mnemonic).toBe(created.mnemonic);\n expect(loaded.account.ss58Address).toBe(created.account.ss58Address);\n });\n\n test(\"clear removes mnemonic from store\", async () => {\n const store = mockLocalKvStore();\n const skm = new SessionKeyManager({ store });\n await skm.create();\n expect(store.data.size).toBe(1);\n\n await skm.clear();\n expect(store.data.size).toBe(0);\n expect(await skm.get()).toBeNull();\n });\n\n test(\"name separates storage keys\", async () => {\n const store = mockLocalKvStore();\n const main = new SessionKeyManager({ name: \"main\", store });\n const burner = new SessionKeyManager({ name: \"burner\", store });\n\n const mainInfo = await main.create();\n const burnerInfo = await burner.create();\n\n expect(store.data.get(\"main\")).toBe(mainInfo.mnemonic);\n expect(store.data.get(\"burner\")).toBe(burnerInfo.mnemonic);\n expect(mainInfo.account.ss58Address).not.toBe(burnerInfo.account.ss58Address);\n\n await main.clear();\n expect(store.data.has(\"main\")).toBe(false);\n expect(store.data.get(\"burner\")).toBe(burnerInfo.mnemonic);\n });\n });\n}\n","/**\n * Canonical sr25519 product-account public-key derivation.\n *\n * Mirrored byte-for-byte by polkadot-desktop\n * (`polkadot-desktop/src/domains/product/account/service.ts`) and conceptually\n * by polkadot-app-android-v2\n * (`feature/products/impl/.../ProductAccountDerivationUseCase.kt`).\n *\n * The function works on the parent *public* key alone: sr25519 soft derivation\n * is composable on public keys, so the CLI / web host / any external client can\n * compute the same derived address that the mobile wallet derives privately,\n * without ever seeing the secret key.\n *\n * Junction path: [\"product\", productId, String(derivationIndex)], applied\n * left-to-right. For each junction, a 32-byte chain code is built:\n * - numeric (\"^\\d+$\") -> SCALE u64 (BigInt), zero-padded to 32 bytes\n * - string -> SCALE str (compact-length + UTF-8), zero-padded\n * - if encoded > 32 bytes -> blake2b256(encoded) (32-byte BLAKE2b digest)\n *\n * # productId constraint (cross-platform parity)\n *\n * `productId` MUST contain at least one non-hex character or be of odd\n * length when serialized as a string. polkadot-app-android-v2's\n * SubstrateJunctionDecoder tries to interpret a junction as hex BEFORE\n * falling through to SCALE-string encoding; polkadot-desktop and this\n * implementation skip that hex branch. For productIds that happen to be\n * even-length all-hex strings (e.g. \"deadbeef\", \"c0ffee01\"), Android would\n * derive a different public key than desktop or this implementation. In\n * practice, productIds are always dotNS names (e.g. \"playground.dot\"),\n * which contain \".\" and therefore never trip the hex branch on Android.\n */\n\nimport { blake2b256 } from \"@parity/product-sdk-crypto\";\nimport { HDKD } from \"@scure/sr25519\";\nimport { str, u64 } from \"scale-ts\";\n\nconst JUNCTION_ID_LEN = 32;\nconst NON_NEGATIVE_INTEGER = /^\\d+$/;\n\nexport function createChainCode(code: string): Uint8Array {\n const encoded = NON_NEGATIVE_INTEGER.test(code) ? u64.enc(BigInt(code)) : str.enc(code);\n\n if (encoded.length > JUNCTION_ID_LEN) {\n return blake2b256(encoded);\n }\n\n const chainCode = new Uint8Array(JUNCTION_ID_LEN);\n chainCode.set(encoded);\n return chainCode;\n}\n\nexport function deriveProductAccountPublicKey(\n parentPublicKey: Uint8Array,\n productId: string,\n derivationIndex: number,\n): Uint8Array {\n const junctions = [\"product\", productId, String(derivationIndex)];\n return junctions.reduce<Uint8Array>(\n (pubkey, junction) => HDKD.publicSoft(pubkey, createChainCode(junction)),\n parentPublicKey,\n );\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@parity/product-sdk-keys",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Hierarchical key derivation and session key management for Polkadot accounts",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -20,10 +20,12 @@
|
|
|
20
20
|
"dependencies": {
|
|
21
21
|
"@polkadot-labs/hdkd": "^0.0.28",
|
|
22
22
|
"@polkadot-labs/hdkd-helpers": "^0.0.10",
|
|
23
|
+
"@scure/sr25519": "^2.2.0",
|
|
23
24
|
"polkadot-api": "^2.1.2",
|
|
25
|
+
"scale-ts": "^1.6.1",
|
|
24
26
|
"@parity/product-sdk-address": "0.1.1",
|
|
25
27
|
"@parity/product-sdk-crypto": "0.1.1",
|
|
26
|
-
"@parity/product-sdk-storage": "0.
|
|
28
|
+
"@parity/product-sdk-local-storage": "0.2.0"
|
|
27
29
|
},
|
|
28
30
|
"devDependencies": {
|
|
29
31
|
"typescript": "^5.7.0",
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* and produces deterministic child keys via HKDF-SHA256, so an app can scope its
|
|
6
6
|
* own keys without ever asking for the user's mnemonic. `SessionKeyManager` is a
|
|
7
7
|
* separate, storage-backed mechanism: it generates a fresh BIP39 mnemonic, keeps
|
|
8
|
-
* it in a {@link
|
|
8
|
+
* it in a {@link LocalKvStore}, and derives an sr25519 account from it — useful for
|
|
9
9
|
* persistent session signers. `seedToAccount` is the dev/test escape hatch that
|
|
10
10
|
* turns a mnemonic and derivation path into a ready-to-use signer.
|
|
11
11
|
*
|
|
@@ -14,4 +14,5 @@
|
|
|
14
14
|
export { KeyManager } from "./key-manager.js";
|
|
15
15
|
export { SessionKeyManager } from "./session-key-manager.js";
|
|
16
16
|
export { seedToAccount } from "./seed-to-account.js";
|
|
17
|
+
export { createChainCode, deriveProductAccountPublicKey } from "./product-account.js";
|
|
17
18
|
export type { DerivedAccount, DerivedKeypairs, SessionKeyInfo } from "./types.js";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { getPublicKey, secretFromSeed } from "@scure/sr25519";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { createChainCode, deriveProductAccountPublicKey } from "./product-account.js";
|
|
4
|
+
|
|
5
|
+
describe("createChainCode", () => {
|
|
6
|
+
it("encodes the numeric junction '0' as 32 zero bytes (u64 LE, zero-padded)", () => {
|
|
7
|
+
const result = createChainCode("0");
|
|
8
|
+
expect(result).toBeInstanceOf(Uint8Array);
|
|
9
|
+
expect(result.length).toBe(32);
|
|
10
|
+
expect(Array.from(result)).toEqual(new Array(32).fill(0));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("encodes the numeric junction '1' as [1, 0×31] (u64 LE, zero-padded)", () => {
|
|
14
|
+
const result = createChainCode("1");
|
|
15
|
+
const expected = new Uint8Array(32);
|
|
16
|
+
expected[0] = 1;
|
|
17
|
+
expect(Array.from(result)).toEqual(Array.from(expected));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("encodes a string junction 'product' as SCALE str + zero-padded to 32 bytes", () => {
|
|
21
|
+
const result = createChainCode("product");
|
|
22
|
+
expect(result.length).toBe(32);
|
|
23
|
+
expect(result[0]).toBe(0x1c); // compact-length: 7 << 2
|
|
24
|
+
expect(new TextDecoder().decode(result.slice(1, 8))).toBe("product");
|
|
25
|
+
expect(Array.from(result.slice(8))).toEqual(new Array(24).fill(0));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("encodes a string junction near the 32-byte boundary without falling back to blake2b", () => {
|
|
29
|
+
const code = "a".repeat(30);
|
|
30
|
+
const result = createChainCode(code);
|
|
31
|
+
expect(result.length).toBe(32);
|
|
32
|
+
expect(result[0]).toBe(30 << 2);
|
|
33
|
+
expect(new TextDecoder().decode(result.slice(1, 31))).toBe(code);
|
|
34
|
+
expect(result[31]).toBe(0);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("hashes a string junction whose SCALE encoding exceeds 32 bytes via blake2b256", () => {
|
|
38
|
+
// The encoded form of "a".repeat(100) is compact-length(100) + 100 utf8 bytes
|
|
39
|
+
// = 2 + 100 = 102 bytes, which exceeds the 32-byte slot and triggers the
|
|
40
|
+
// blake2b fallback. The expected hex value below is the blake2b-256 hash
|
|
41
|
+
// of the SCALE-encoded bytes; locking it pins us to a specific hash function.
|
|
42
|
+
const longCode = "a".repeat(100);
|
|
43
|
+
const result = createChainCode(longCode);
|
|
44
|
+
expect(result.length).toBe(32);
|
|
45
|
+
const hex = `0x${Array.from(result)
|
|
46
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
47
|
+
.join("")}`;
|
|
48
|
+
// Compute this once locally and paste below. To regenerate after a deliberate
|
|
49
|
+
// algorithm change, run the assertion, copy the actual hex from the failure
|
|
50
|
+
// diff, and update.
|
|
51
|
+
expect(hex).toBe("0x0cc6ae1565611349f15a291549fc38c30273d4bf600eec3ec6dfffff6d5bb8d8");
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Helpers shared by the frozen-vector block below
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function pubKeyFromSeedByte(byte: number): Uint8Array {
|
|
60
|
+
const seed = new Uint8Array(32).fill(byte);
|
|
61
|
+
const secretKey = secretFromSeed(seed);
|
|
62
|
+
return getPublicKey(secretKey);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function toHex(bytes: Uint8Array): string {
|
|
66
|
+
return `0x${Array.from(bytes)
|
|
67
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
68
|
+
.join("")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("deriveProductAccountPublicKey (frozen vectors)", () => {
|
|
72
|
+
it("playground.dot / index 0, parent pubkey from seed byte 0x00", () => {
|
|
73
|
+
const root = pubKeyFromSeedByte(0);
|
|
74
|
+
const result = deriveProductAccountPublicKey(root, "playground.dot", 0);
|
|
75
|
+
expect(toHex(result)).toBe(
|
|
76
|
+
"0xc2beceb2dd5d6011d03647374c6f21fbab1132d2b7bc872de4edf249952fb525",
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("playground.dot / index 1, parent pubkey from seed byte 0x01 (non-zero u64 branch)", () => {
|
|
81
|
+
const root = pubKeyFromSeedByte(1);
|
|
82
|
+
const result = deriveProductAccountPublicKey(root, "playground.dot", 1);
|
|
83
|
+
expect(toHex(result)).toBe(
|
|
84
|
+
"0x886a3a296d26b4971e066631f5a1dbb7ad7db61468e1bd9429353ff93afac622",
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("near-boundary productId, parent pubkey from seed byte 0x02 (no blake2b fallback)", () => {
|
|
89
|
+
const root = pubKeyFromSeedByte(2);
|
|
90
|
+
const result = deriveProductAccountPublicKey(root, "a-very-long-product.dot", 0);
|
|
91
|
+
expect(toHex(result)).toBe(
|
|
92
|
+
"0xa04c8edbb5c77fd8bd934a1d0c60b7d9d2eeed870ac7d35a94a10a91451d8f04",
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("long productId triggers blake2b fallback for the second junction, parent from seed byte 0x03", () => {
|
|
97
|
+
const root = pubKeyFromSeedByte(3);
|
|
98
|
+
const result = deriveProductAccountPublicKey(
|
|
99
|
+
root,
|
|
100
|
+
"this-name-is-deliberately-long-enough-to-trip-the-fallback.dot",
|
|
101
|
+
0,
|
|
102
|
+
);
|
|
103
|
+
expect(toHex(result)).toBe(
|
|
104
|
+
"0x5cbabd54efcc45d1d4c1dc8b87b02ffd2ba5e70584ac005e7bd3484810054605",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical sr25519 product-account public-key derivation.
|
|
3
|
+
*
|
|
4
|
+
* Mirrored byte-for-byte by polkadot-desktop
|
|
5
|
+
* (`polkadot-desktop/src/domains/product/account/service.ts`) and conceptually
|
|
6
|
+
* by polkadot-app-android-v2
|
|
7
|
+
* (`feature/products/impl/.../ProductAccountDerivationUseCase.kt`).
|
|
8
|
+
*
|
|
9
|
+
* The function works on the parent *public* key alone: sr25519 soft derivation
|
|
10
|
+
* is composable on public keys, so the CLI / web host / any external client can
|
|
11
|
+
* compute the same derived address that the mobile wallet derives privately,
|
|
12
|
+
* without ever seeing the secret key.
|
|
13
|
+
*
|
|
14
|
+
* Junction path: ["product", productId, String(derivationIndex)], applied
|
|
15
|
+
* left-to-right. For each junction, a 32-byte chain code is built:
|
|
16
|
+
* - numeric ("^\d+$") -> SCALE u64 (BigInt), zero-padded to 32 bytes
|
|
17
|
+
* - string -> SCALE str (compact-length + UTF-8), zero-padded
|
|
18
|
+
* - if encoded > 32 bytes -> blake2b256(encoded) (32-byte BLAKE2b digest)
|
|
19
|
+
*
|
|
20
|
+
* # productId constraint (cross-platform parity)
|
|
21
|
+
*
|
|
22
|
+
* `productId` MUST contain at least one non-hex character or be of odd
|
|
23
|
+
* length when serialized as a string. polkadot-app-android-v2's
|
|
24
|
+
* SubstrateJunctionDecoder tries to interpret a junction as hex BEFORE
|
|
25
|
+
* falling through to SCALE-string encoding; polkadot-desktop and this
|
|
26
|
+
* implementation skip that hex branch. For productIds that happen to be
|
|
27
|
+
* even-length all-hex strings (e.g. "deadbeef", "c0ffee01"), Android would
|
|
28
|
+
* derive a different public key than desktop or this implementation. In
|
|
29
|
+
* practice, productIds are always dotNS names (e.g. "playground.dot"),
|
|
30
|
+
* which contain "." and therefore never trip the hex branch on Android.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { blake2b256 } from "@parity/product-sdk-crypto";
|
|
34
|
+
import { HDKD } from "@scure/sr25519";
|
|
35
|
+
import { str, u64 } from "scale-ts";
|
|
36
|
+
|
|
37
|
+
const JUNCTION_ID_LEN = 32;
|
|
38
|
+
const NON_NEGATIVE_INTEGER = /^\d+$/;
|
|
39
|
+
|
|
40
|
+
export function createChainCode(code: string): Uint8Array {
|
|
41
|
+
const encoded = NON_NEGATIVE_INTEGER.test(code) ? u64.enc(BigInt(code)) : str.enc(code);
|
|
42
|
+
|
|
43
|
+
if (encoded.length > JUNCTION_ID_LEN) {
|
|
44
|
+
return blake2b256(encoded);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const chainCode = new Uint8Array(JUNCTION_ID_LEN);
|
|
48
|
+
chainCode.set(encoded);
|
|
49
|
+
return chainCode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function deriveProductAccountPublicKey(
|
|
53
|
+
parentPublicKey: Uint8Array,
|
|
54
|
+
productId: string,
|
|
55
|
+
derivationIndex: number,
|
|
56
|
+
): Uint8Array {
|
|
57
|
+
const junctions = ["product", productId, String(derivationIndex)];
|
|
58
|
+
return junctions.reduce<Uint8Array>(
|
|
59
|
+
(pubkey, junction) => HDKD.publicSoft(pubkey, createChainCode(junction)),
|
|
60
|
+
parentPublicKey,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { generateMnemonic } from "@polkadot-labs/hdkd-helpers";
|
|
2
|
-
import type {
|
|
2
|
+
import type { LocalKvStore } from "@parity/product-sdk-local-storage";
|
|
3
3
|
|
|
4
4
|
import { seedToAccount } from "./seed-to-account.js";
|
|
5
5
|
import type { SessionKeyInfo } from "./types.js";
|
|
@@ -7,23 +7,23 @@ import type { SessionKeyInfo } from "./types.js";
|
|
|
7
7
|
/**
|
|
8
8
|
* Manages an sr25519 account derived from a BIP39 mnemonic.
|
|
9
9
|
*
|
|
10
|
-
* @param options.store -
|
|
11
|
-
* Create with `
|
|
10
|
+
* @param options.store - LocalKvStore instance (from `@parity/product-sdk-local-storage`).
|
|
11
|
+
* Create with `createLocalKvStore({ prefix: "session-key" })` for namespaced persistence.
|
|
12
12
|
* @param options.name - Identifies this session key. Defaults to `"default"`.
|
|
13
13
|
* Use different names to manage multiple independent session keys.
|
|
14
14
|
*
|
|
15
15
|
* @example
|
|
16
16
|
* ```ts
|
|
17
|
-
* const store = await
|
|
17
|
+
* const store = await createLocalKvStore({ prefix: "session-key" });
|
|
18
18
|
* const skm = new SessionKeyManager({ store });
|
|
19
19
|
* const key = await skm.getOrCreate();
|
|
20
20
|
* ```
|
|
21
21
|
*/
|
|
22
22
|
export class SessionKeyManager {
|
|
23
23
|
private readonly name: string;
|
|
24
|
-
private readonly store:
|
|
24
|
+
private readonly store: LocalKvStore;
|
|
25
25
|
|
|
26
|
-
constructor(options: { store:
|
|
26
|
+
constructor(options: { store: LocalKvStore; name?: string }) {
|
|
27
27
|
this.name = options.name ?? "default";
|
|
28
28
|
this.store = options.store;
|
|
29
29
|
}
|
|
@@ -78,7 +78,7 @@ if (import.meta.vitest) {
|
|
|
78
78
|
const TEST_MNEMONIC =
|
|
79
79
|
"abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
|
|
80
80
|
|
|
81
|
-
function
|
|
81
|
+
function mockLocalKvStore(): LocalKvStore & { data: Map<string, string> } {
|
|
82
82
|
const data = new Map<string, string>();
|
|
83
83
|
return {
|
|
84
84
|
data,
|
|
@@ -100,7 +100,7 @@ if (import.meta.vitest) {
|
|
|
100
100
|
|
|
101
101
|
describe("SessionKeyManager", () => {
|
|
102
102
|
test("fromMnemonic produces deterministic results", () => {
|
|
103
|
-
const store =
|
|
103
|
+
const store = mockLocalKvStore();
|
|
104
104
|
const skm = new SessionKeyManager({ store });
|
|
105
105
|
const a = skm.fromMnemonic(TEST_MNEMONIC);
|
|
106
106
|
const b = skm.fromMnemonic(TEST_MNEMONIC);
|
|
@@ -110,13 +110,13 @@ if (import.meta.vitest) {
|
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
test("fromMnemonic throws on invalid mnemonic", () => {
|
|
113
|
-
const store =
|
|
113
|
+
const store = mockLocalKvStore();
|
|
114
114
|
const skm = new SessionKeyManager({ store });
|
|
115
115
|
expect(() => skm.fromMnemonic("invalid words here")).toThrow("Invalid mnemonic phrase");
|
|
116
116
|
});
|
|
117
117
|
|
|
118
118
|
test("create and get round-trip", async () => {
|
|
119
|
-
const store =
|
|
119
|
+
const store = mockLocalKvStore();
|
|
120
120
|
const skm = new SessionKeyManager({ store });
|
|
121
121
|
const info = await skm.create();
|
|
122
122
|
expect(info.mnemonic).toBeTruthy();
|
|
@@ -128,13 +128,13 @@ if (import.meta.vitest) {
|
|
|
128
128
|
});
|
|
129
129
|
|
|
130
130
|
test("get returns null when no key stored", async () => {
|
|
131
|
-
const store =
|
|
131
|
+
const store = mockLocalKvStore();
|
|
132
132
|
const skm = new SessionKeyManager({ store });
|
|
133
133
|
expect(await skm.get()).toBeNull();
|
|
134
134
|
});
|
|
135
135
|
|
|
136
136
|
test("getOrCreate creates then returns cached", async () => {
|
|
137
|
-
const store =
|
|
137
|
+
const store = mockLocalKvStore();
|
|
138
138
|
const skm = new SessionKeyManager({ store });
|
|
139
139
|
const created = await skm.getOrCreate();
|
|
140
140
|
expect(store.data.size).toBe(1);
|
|
@@ -145,7 +145,7 @@ if (import.meta.vitest) {
|
|
|
145
145
|
});
|
|
146
146
|
|
|
147
147
|
test("clear removes mnemonic from store", async () => {
|
|
148
|
-
const store =
|
|
148
|
+
const store = mockLocalKvStore();
|
|
149
149
|
const skm = new SessionKeyManager({ store });
|
|
150
150
|
await skm.create();
|
|
151
151
|
expect(store.data.size).toBe(1);
|
|
@@ -156,7 +156,7 @@ if (import.meta.vitest) {
|
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
test("name separates storage keys", async () => {
|
|
159
|
-
const store =
|
|
159
|
+
const store = mockLocalKvStore();
|
|
160
160
|
const main = new SessionKeyManager({ name: "main", store });
|
|
161
161
|
const burner = new SessionKeyManager({ name: "burner", store });
|
|
162
162
|
|