Skip to content

Commit 21c422c

Browse files
authored
Implement the flow using the recovery flow happy path (#3453)
* Recovery flow happy path * Refactor to a function * Support pasting words * Use global agent options and submit on enter or word pasting * Revert and improve comments * Remove unnecessary comment and unused import * Remove unnecessary import * GH review changes * Change submit trigger * Refactor to throw instead of variant
1 parent 8bd0698 commit 21c422c

File tree

4 files changed

+412
-11
lines changed

4 files changed

+412
-11
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import {
2+
DeviceKey,
3+
IdentityInfo,
4+
} from "$lib/generated/internet_identity_types";
5+
import { agentOptions, anonymousActor } from "$lib/globals";
6+
import {
7+
fromMnemonicWithoutValidation,
8+
IC_DERIVATION_PATH,
9+
isValidMnemonic,
10+
} from "$lib/utils/recoveryPhrase";
11+
import { throwCanisterError } from "$lib/utils/utils";
12+
import { DerEncodedPublicKey, HttpAgent, SignIdentity } from "@dfinity/agent";
13+
import {
14+
DelegationChain,
15+
DelegationIdentity,
16+
ECDSAKeyIdentity,
17+
} from "@icp-sdk/core/identity";
18+
19+
const authenticateWithRecoveryPhrase = async (
20+
identity: SignIdentity,
21+
): Promise<DelegationIdentity> => {
22+
const sessionKey = await ECDSAKeyIdentity.generate({ extractable: false });
23+
const tenMinutesInMsec = 10 * 1000 * 60;
24+
const chain = await DelegationChain.create(
25+
identity,
26+
sessionKey.getPublicKey(),
27+
new Date(Date.now() + tenMinutesInMsec),
28+
);
29+
return DelegationIdentity.fromDelegation(sessionKey, chain);
30+
};
31+
32+
const bufferEqual = (buf1: ArrayBuffer, buf2: ArrayBuffer): boolean => {
33+
if (buf1.byteLength !== buf2.byteLength) return false;
34+
const dv1 = new Int8Array(buf1);
35+
const dv2 = new Int8Array(buf2);
36+
for (let i = 0; i !== buf1.byteLength; i++) {
37+
if (dv1[i] !== dv2[i]) return false;
38+
}
39+
return true;
40+
};
41+
42+
const derFromPubkey = (pubkey: DeviceKey): DerEncodedPublicKey =>
43+
new Uint8Array(pubkey).buffer as DerEncodedPublicKey;
44+
45+
export class InvalidMnemonicError extends Error {
46+
constructor() {
47+
super("Invalid mnemonic");
48+
}
49+
}
50+
51+
export const recoverWithPhrase = async (
52+
words: string[],
53+
): Promise<{ info: IdentityInfo; identity: DelegationIdentity }> => {
54+
const recoveryPhrase = words.join(" ");
55+
if (!isValidMnemonic(recoveryPhrase)) {
56+
throw new InvalidMnemonicError();
57+
}
58+
const identity = await fromMnemonicWithoutValidation(
59+
recoveryPhrase,
60+
IC_DERIVATION_PATH,
61+
);
62+
// TODO: Use lookup endpoint of recovery phrase to user number.
63+
const userNumber = BigInt(window.prompt("Identity number")!);
64+
const devices = await anonymousActor.get_anchor_credentials(userNumber);
65+
const isCorrectPhrase = devices.recovery_phrases.some((pubkey) =>
66+
bufferEqual(identity.getPublicKey().toDer(), derFromPubkey(pubkey)),
67+
);
68+
if (!isCorrectPhrase) {
69+
throw new Error("Invalid phrase");
70+
}
71+
const delegationIdentity = await authenticateWithRecoveryPhrase(identity);
72+
const agent = HttpAgent.createSync({
73+
...agentOptions,
74+
identity: delegationIdentity,
75+
});
76+
// Make call to lookup endpoint
77+
const identityInfo = await anonymousActor.identity_info
78+
.withOptions({ agent })(userNumber)
79+
.then(throwCanisterError);
80+
return {
81+
info: identityInfo,
82+
identity: delegationIdentity,
83+
};
84+
};
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { hexToBytes } from "@noble/hashes/utils";
2+
import { Ed25519PublicKey } from "@icp-sdk/core/identity";
3+
import * as ed25519 from "./recoveryPhrase";
4+
5+
type TestVector = {
6+
seed: string;
7+
privateKey: string;
8+
publicKey: string;
9+
derivationPath?: number[];
10+
};
11+
12+
// Test vectors are taken from
13+
// https://github.com/satoshilabs/slips/blob/master/slip-0010.md
14+
// The public key vectors contained a leading 0-byte for no obvious reason.
15+
// These were removed.
16+
const testVectorsSLIP10 = [
17+
{
18+
seed: "000102030405060708090a0b0c0d0e0f",
19+
privateKey:
20+
"2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7",
21+
publicKey:
22+
"a4b2856bfec510abab89753fac1ac0e1112364e7d250545963f135f2a33188ed",
23+
},
24+
{
25+
seed: "000102030405060708090a0b0c0d0e0f",
26+
privateKey:
27+
"68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3",
28+
publicKey:
29+
"8c8a13df77a28f3445213a0f432fde644acaa215fc72dcdf300d5efaa85d350c",
30+
derivationPath: [0],
31+
},
32+
{
33+
seed: "000102030405060708090a0b0c0d0e0f",
34+
privateKey:
35+
"b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2",
36+
publicKey:
37+
"1932a5270f335bed617d5b935c80aedb1a35bd9fc1e31acafd5372c30f5c1187",
38+
derivationPath: [0, 1],
39+
},
40+
{
41+
seed: "000102030405060708090a0b0c0d0e0f",
42+
privateKey:
43+
"92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9",
44+
publicKey:
45+
"ae98736566d30ed0e9d2f4486a64bc95740d89c7db33f52121f8ea8f76ff0fc1",
46+
derivationPath: [0, 1, 2],
47+
},
48+
{
49+
seed: "000102030405060708090a0b0c0d0e0f",
50+
privateKey:
51+
"30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662",
52+
publicKey:
53+
"8abae2d66361c879b900d204ad2cc4984fa2aa344dd7ddc46007329ac76c429c",
54+
derivationPath: [0, 1, 2, 2],
55+
},
56+
{
57+
seed: "000102030405060708090a0b0c0d0e0f",
58+
privateKey:
59+
"8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793",
60+
publicKey:
61+
"3c24da049451555d51a7014a37337aa4e12d41e485abccfa46b47dfb2af54b7a",
62+
derivationPath: [0, 1, 2, 2, 1000000000],
63+
},
64+
];
65+
66+
test("derive Ed25519 via SLIP 0010", async () => {
67+
await Promise.all(
68+
testVectorsSLIP10.map(async (testVector: TestVector) => {
69+
const seedBlob = hexToBytes(testVector.seed);
70+
const expectedPrivateKey = hexToBytes(testVector.privateKey);
71+
const expectedPublicKey = hexToBytes(testVector.publicKey);
72+
73+
const identity = await ed25519.fromSeedWithSlip0010(
74+
new Uint8Array(seedBlob),
75+
testVector.derivationPath,
76+
);
77+
78+
const keyPair = identity.getKeyPair();
79+
expect(keyPair.secretKey.slice(0, 32)).toEqual(
80+
new Uint8Array(expectedPrivateKey),
81+
);
82+
expect(keyPair.publicKey.toDer()).toEqual(
83+
Ed25519PublicKey.fromRaw(expectedPublicKey).toDer(),
84+
);
85+
}),
86+
);
87+
});
88+
89+
test("Can derive identity from invalid mnemonic", async () => {
90+
await expect(
91+
ed25519.fromMnemonicWithoutValidation(""),
92+
).resolves.not.toThrow();
93+
await expect(
94+
ed25519.fromMnemonicWithoutValidation("g4rb4g3"),
95+
).resolves.not.toThrow();
96+
await expect(
97+
ed25519.fromMnemonicWithoutValidation("basket actual"),
98+
).resolves.not.toThrow();
99+
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Ed25519KeyIdentity } from "@icp-sdk/core/identity";
2+
import { mnemonicToSeedSync, validateMnemonic } from "bip39";
3+
4+
export const IC_DERIVATION_PATH = [44, 223, 0, 0, 0];
5+
6+
// A constant used for xor-ing derived paths to make them hardened.
7+
const HARDENED = 0x80000000;
8+
9+
/**
10+
* Validates a mnemonic phrase using BIP-39.
11+
*
12+
* @param mnemonic A BIP-39 mnemonic phrase.
13+
* @returns {boolean} True if the mnemonic is valid, false otherwise.
14+
*/
15+
export const isValidMnemonic = (mnemonic: string): boolean =>
16+
validateMnemonic(mnemonic);
17+
18+
/**
19+
* Create an Ed25519 according to SLIP 0010:
20+
* https://github.com/satoshilabs/slips/blob/master/slip-0010.md
21+
*
22+
* The derivation path is an array that is always interpreted as a hardened path.
23+
* e.g. to generate m/44'/223’/0’/0’/0' the derivation path should be [44, 223, 0, 0, 0]
24+
*/
25+
export async function fromSeedWithSlip0010(
26+
masterSeed: Uint8Array,
27+
derivationPath: number[] = [],
28+
): Promise<Ed25519KeyIdentity> {
29+
let [slipSeed, chainCode] = await generateMasterKey(masterSeed);
30+
31+
for (let i = 0; i < derivationPath.length; i++) {
32+
[slipSeed, chainCode] = await derive(
33+
slipSeed,
34+
chainCode,
35+
derivationPath[i] | HARDENED,
36+
);
37+
}
38+
39+
return Ed25519KeyIdentity.generate(slipSeed);
40+
}
41+
42+
/**
43+
* Create an Ed25519 based on a mnemonic phrase according to SLIP 0010:
44+
* https://github.com/satoshilabs/slips/blob/master/slip-0010.md
45+
*
46+
* NOTE: This method derives an identity even if the mnemonic is invalid. It's
47+
* the responsibility of the caller to validate the mnemonic before calling this method.
48+
*
49+
* @param mnemonic A BIP-39 mnemonic.
50+
* @param derivationPath an array that is always interpreted as a hardened path.
51+
* e.g. to generate m/44'/223’/0’/0’/0' the derivation path should be [44, 223, 0, 0, 0]
52+
*/
53+
export function fromMnemonicWithoutValidation(
54+
mnemonic: string,
55+
derivationPath: number[] = [],
56+
): Promise<Ed25519KeyIdentity> {
57+
const seed = mnemonicToSeedSync(mnemonic);
58+
return fromSeedWithSlip0010(seed, derivationPath);
59+
}
60+
61+
async function generateMasterKey(
62+
seed: Uint8Array,
63+
): Promise<[Uint8Array, Uint8Array]> {
64+
const data = new TextEncoder().encode("ed25519 seed");
65+
const key = await window.crypto.subtle.importKey(
66+
"raw",
67+
data,
68+
{
69+
name: "HMAC",
70+
hash: { name: "SHA-512" },
71+
},
72+
false,
73+
["sign"],
74+
);
75+
const h = await window.crypto.subtle.sign("HMAC", key, seed);
76+
const slipSeed = new Uint8Array(h.slice(0, 32));
77+
const chainCode = new Uint8Array(h.slice(32));
78+
return [slipSeed, chainCode];
79+
}
80+
81+
async function derive(
82+
parentKey: Uint8Array,
83+
parentChaincode: Uint8Array,
84+
i: number,
85+
): Promise<[Uint8Array, Uint8Array]> {
86+
// From the spec: Data = 0x00 || ser256(kpar) || ser32(i)
87+
const data = new Uint8Array([0, ...parentKey, ...toBigEndianArray(i)]);
88+
const key = await window.crypto.subtle.importKey(
89+
"raw",
90+
parentChaincode,
91+
{
92+
name: "HMAC",
93+
hash: { name: "SHA-512" },
94+
},
95+
false,
96+
["sign"],
97+
);
98+
99+
const h = await window.crypto.subtle.sign("HMAC", key, data);
100+
const slipSeed = new Uint8Array(h.slice(0, 32));
101+
const chainCode = new Uint8Array(h.slice(32));
102+
return [slipSeed, chainCode];
103+
}
104+
105+
// Converts a 32-bit unsigned integer to a big endian byte array.
106+
function toBigEndianArray(n: number): Uint8Array {
107+
const byteArray = new Uint8Array([0, 0, 0, 0]);
108+
for (let i = byteArray.length - 1; i >= 0; i--) {
109+
const byte = n & 0xff;
110+
byteArray[i] = byte;
111+
n = (n - byte) / 256;
112+
}
113+
return byteArray;
114+
}

0 commit comments

Comments
 (0)