@me2em/react 0.1.0-alpha.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/LICENSE +201 -0
- package/README.md +195 -0
- package/dist/index.d.ts +367 -0
- package/dist/index.js +1225 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/headless/context-crypto.ts +91 -0
- package/src/headless/context-key-store.ts +124 -0
- package/src/headless/display-flow.ts +113 -0
- package/src/headless/identity-context-storage.ts +205 -0
- package/src/headless/identity-context-types.ts +30 -0
- package/src/headless/identity-flow.ts +106 -0
- package/src/headless/import-flow.ts +82 -0
- package/src/headless/index.ts +53 -0
- package/src/headless/seed-words.ts +75 -0
- package/src/headless/types.ts +21 -0
- package/src/headless/verify-flow.ts +112 -0
- package/src/index.ts +2 -0
- package/src/react/Me2emProvider.tsx +33 -0
- package/src/react/PassphraseInput.tsx +37 -0
- package/src/react/SeedPhraseDisplay.tsx +92 -0
- package/src/react/SeedPhraseImport.tsx +110 -0
- package/src/react/SeedPhraseVerify.tsx +128 -0
- package/src/react/context.ts +19 -0
- package/src/react/index.ts +23 -0
- package/src/react/useCreateIdentity.ts +77 -0
- package/src/react/useHandle.ts +27 -0
- package/src/react/useIdentityContext.ts +88 -0
- package/src/react/useImportSeed.ts +60 -0
- package/src/react/useSession.ts +157 -0
- package/src/react/useSubHandle.ts +30 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// packages/react/src/headless/identity-context-storage.ts
|
|
2
|
+
import { encryptContext, decryptContext, type ContextEnvelope } from './context-crypto.js';
|
|
3
|
+
import type { IdentityContextData, IdentityContextStorage } from './identity-context-types.js';
|
|
4
|
+
export type { IdentityContextData };
|
|
5
|
+
|
|
6
|
+
const DB_NAME_PREFIX = 'me2em_';
|
|
7
|
+
const STORE_NAME = 'context';
|
|
8
|
+
const KEYS_STORE_NAME = 'cryptoKeys';
|
|
9
|
+
const CACHE_KEY = 'cache';
|
|
10
|
+
|
|
11
|
+
function hashToHex(data: Uint8Array): string {
|
|
12
|
+
const hexParts: string[] = [];
|
|
13
|
+
for (const byte of data) {
|
|
14
|
+
hexParts.push(byte.toString(16).padStart(2, '0'));
|
|
15
|
+
}
|
|
16
|
+
return hexParts.join('');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function sha256Hex(data: Uint8Array): Promise<string> {
|
|
20
|
+
const buffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
|
|
21
|
+
const digest: ArrayBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
|
22
|
+
return hashToHex(new Uint8Array(digest));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function dbNameForIdentity(identityId: string): Promise<string> {
|
|
26
|
+
const hash = await sha256Hex(new TextEncoder().encode(identityId));
|
|
27
|
+
return DB_NAME_PREFIX + hash.slice(0, 16);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class IdentityContextIDBStorage implements IdentityContextStorage {
|
|
31
|
+
private db: IDBDatabase | null = null;
|
|
32
|
+
private currentIdentityId: string | null = null;
|
|
33
|
+
private cacheKey: CryptoKey | null = null;
|
|
34
|
+
|
|
35
|
+
private async getDb(): Promise<IDBDatabase> {
|
|
36
|
+
if (!this.db) {
|
|
37
|
+
throw new Error('Storage not opened — call open() first');
|
|
38
|
+
}
|
|
39
|
+
return this.db;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async open(identityId: string): Promise<void> {
|
|
43
|
+
if (this.currentIdentityId !== null && this.currentIdentityId !== identityId) {
|
|
44
|
+
await this.close();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
this.currentIdentityId = identityId;
|
|
48
|
+
const name = await dbNameForIdentity(identityId);
|
|
49
|
+
|
|
50
|
+
return new Promise<void>((resolve, reject) => {
|
|
51
|
+
const request = indexedDB.open(name, 1);
|
|
52
|
+
|
|
53
|
+
request.onupgradeneeded = () => {
|
|
54
|
+
const db = request.result;
|
|
55
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
56
|
+
db.createObjectStore(STORE_NAME, { keyPath: 'key' });
|
|
57
|
+
}
|
|
58
|
+
if (!db.objectStoreNames.contains(KEYS_STORE_NAME)) {
|
|
59
|
+
db.createObjectStore(KEYS_STORE_NAME, { keyPath: 'key' });
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
request.onsuccess = () => {
|
|
64
|
+
this.db = request.result;
|
|
65
|
+
resolve();
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
request.onerror = () => {
|
|
69
|
+
reject(new Error('IndexedDB open failed: ' + request.error?.message));
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async openAndLoad(identityId: string): Promise<IdentityContextData | null> {
|
|
75
|
+
await this.open(identityId);
|
|
76
|
+
const key = this.cacheKey ?? await this.loadPersistedKey();
|
|
77
|
+
if (!key) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
this.cacheKey = key;
|
|
81
|
+
return this.load();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async save(data: IdentityContextData): Promise<void> {
|
|
85
|
+
const key = this.cacheKey;
|
|
86
|
+
if (!key) {
|
|
87
|
+
throw new Error('No cache key set — call setCacheKey before save');
|
|
88
|
+
}
|
|
89
|
+
const envelope = await encryptContext(key, data);
|
|
90
|
+
const db = await this.getDb();
|
|
91
|
+
return new Promise<void>((resolve, reject) => {
|
|
92
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
93
|
+
const store = tx.objectStore(STORE_NAME);
|
|
94
|
+
store.put({ key: CACHE_KEY, envelope });
|
|
95
|
+
|
|
96
|
+
tx.oncomplete = () => resolve();
|
|
97
|
+
tx.onerror = () => reject(new Error('Save failed: ' + tx.error?.message));
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async load(): Promise<IdentityContextData | null> {
|
|
102
|
+
const db = await this.getDb();
|
|
103
|
+
const key = this.cacheKey;
|
|
104
|
+
if (!key) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return new Promise<IdentityContextData | null>((resolve, reject) => {
|
|
108
|
+
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
109
|
+
const store = tx.objectStore(STORE_NAME);
|
|
110
|
+
const request = store.get(CACHE_KEY);
|
|
111
|
+
|
|
112
|
+
request.onsuccess = () => {
|
|
113
|
+
const record = request.result;
|
|
114
|
+
if (!record || !record.envelope) {
|
|
115
|
+
resolve(null);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
decryptContext(key, record.envelope)
|
|
119
|
+
.then((data) => {
|
|
120
|
+
if (data.identityId !== this.currentIdentityId) {
|
|
121
|
+
resolve(null);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
resolve(data);
|
|
125
|
+
})
|
|
126
|
+
.catch(() => {
|
|
127
|
+
resolve(null);
|
|
128
|
+
});
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
request.onerror = () => reject(new Error('Load failed: ' + request.error?.message));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async clear(): Promise<void> {
|
|
136
|
+
const db = await this.getDb();
|
|
137
|
+
return new Promise<void>((resolve, reject) => {
|
|
138
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
139
|
+
const store = tx.objectStore(STORE_NAME);
|
|
140
|
+
store.delete(CACHE_KEY);
|
|
141
|
+
|
|
142
|
+
tx.oncomplete = () => resolve();
|
|
143
|
+
tx.onerror = () => reject(new Error('Clear failed: ' + tx.error?.message));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async close(): Promise<void> {
|
|
148
|
+
const db = this.db;
|
|
149
|
+
this.db = null;
|
|
150
|
+
this.currentIdentityId = null;
|
|
151
|
+
this.cacheKey = null;
|
|
152
|
+
if (db) {
|
|
153
|
+
db.close();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
setCacheKey(key: CryptoKey): void {
|
|
158
|
+
this.cacheKey = key;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
getCacheKey(): CryptoKey | null {
|
|
162
|
+
return this.cacheKey;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async persistCacheKey(): Promise<void> {
|
|
166
|
+
const key = this.cacheKey;
|
|
167
|
+
if (!key) {
|
|
168
|
+
throw new Error('No cache key to persist — call setCacheKey first');
|
|
169
|
+
}
|
|
170
|
+
const db = await this.getDb();
|
|
171
|
+
return new Promise<void>((resolve, reject) => {
|
|
172
|
+
const tx = db.transaction(KEYS_STORE_NAME, 'readwrite');
|
|
173
|
+
const store = tx.objectStore(KEYS_STORE_NAME);
|
|
174
|
+
store.put({ key: 'cache', cryptoKey: key });
|
|
175
|
+
|
|
176
|
+
tx.oncomplete = () => resolve();
|
|
177
|
+
tx.onerror = () => reject(new Error('persistCacheKey failed: ' + tx.error?.message));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async loadPersistedKey(): Promise<CryptoKey | null> {
|
|
182
|
+
const db = this.db;
|
|
183
|
+
if (!db) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return new Promise<CryptoKey | null>((resolve, reject) => {
|
|
187
|
+
const tx = db.transaction(KEYS_STORE_NAME, 'readonly');
|
|
188
|
+
const store = tx.objectStore(KEYS_STORE_NAME);
|
|
189
|
+
const request = store.get('cache');
|
|
190
|
+
|
|
191
|
+
request.onsuccess = () => {
|
|
192
|
+
const record = request.result;
|
|
193
|
+
if (!record || !record.cryptoKey) {
|
|
194
|
+
resolve(null);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
resolve(record.cryptoKey);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
request.onerror = () => {
|
|
201
|
+
resolve(null);
|
|
202
|
+
};
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// packages/react/src/headless/identity-context-types.ts
|
|
2
|
+
export interface IdentityContextData {
|
|
3
|
+
/** base64url identity public key — владелец кэша. */
|
|
4
|
+
identityId: string;
|
|
5
|
+
/** Реестр Handle-контекстов этой Identity. */
|
|
6
|
+
handles: Array<{
|
|
7
|
+
name: string;
|
|
8
|
+
subHandles?: string[];
|
|
9
|
+
meta?: Record<string, unknown>;
|
|
10
|
+
}>;
|
|
11
|
+
savedAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IdentityContextStorage {
|
|
15
|
+
open(identityId: string): Promise<void>;
|
|
16
|
+
save(data: IdentityContextData): Promise<void>;
|
|
17
|
+
load(): Promise<IdentityContextData | null>;
|
|
18
|
+
clear(): Promise<void>;
|
|
19
|
+
close(): Promise<void>;
|
|
20
|
+
/** Open storage for identity and atomically load cached context. */
|
|
21
|
+
openAndLoad(identityId: string): Promise<IdentityContextData | null>;
|
|
22
|
+
/** Set the persisted cache key (called at login). */
|
|
23
|
+
setCacheKey(key: CryptoKey): void;
|
|
24
|
+
/** Get the currently set cache key, or null. */
|
|
25
|
+
getCacheKey(): CryptoKey | null;
|
|
26
|
+
/** Persist the CryptoKey to IndexedDB for warm return after browser restart. */
|
|
27
|
+
persistCacheKey(): Promise<void>;
|
|
28
|
+
/** Load a previously persisted CryptoKey from IndexedDB. */
|
|
29
|
+
loadPersistedKey(): Promise<CryptoKey | null>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { IdentityFlowState, IdentityFlowEvent } from './types.js';
|
|
2
|
+
|
|
3
|
+
export const initialIdentityFlowState: IdentityFlowState = {
|
|
4
|
+
status: 'idle',
|
|
5
|
+
seedWords: null,
|
|
6
|
+
identitySeed: null,
|
|
7
|
+
error: null,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const UNEXPECTED_EVENTS: Record<string, string> = {
|
|
11
|
+
CONFIRM_WORDS: 'CONFIRM_WORDS called from idle — no seed to confirm',
|
|
12
|
+
RESET: 'RESET called from idle — nothing to reset',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function identityFlowReducer(
|
|
16
|
+
state: IdentityFlowState,
|
|
17
|
+
event: IdentityFlowEvent
|
|
18
|
+
): IdentityFlowState {
|
|
19
|
+
const { type } = event;
|
|
20
|
+
|
|
21
|
+
switch (state.status) {
|
|
22
|
+
case 'idle': {
|
|
23
|
+
if (type === 'GENERATE') {
|
|
24
|
+
return {
|
|
25
|
+
status: 'seed-generated',
|
|
26
|
+
seedWords: event.words,
|
|
27
|
+
identitySeed: event.seed,
|
|
28
|
+
error: null,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (type === 'IMPORT') {
|
|
32
|
+
return {
|
|
33
|
+
status: 'imported',
|
|
34
|
+
seedWords: null,
|
|
35
|
+
identitySeed: event.seed,
|
|
36
|
+
error: null,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (type === 'FAIL') {
|
|
40
|
+
return {
|
|
41
|
+
status: 'error',
|
|
42
|
+
seedWords: null,
|
|
43
|
+
identitySeed: null,
|
|
44
|
+
error: event.message,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (UNEXPECTED_EVENTS[type]) {
|
|
48
|
+
console.warn(UNEXPECTED_EVENTS[type]);
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
return state;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
case 'seed-generated': {
|
|
55
|
+
if (type === 'CONFIRM_WORDS') {
|
|
56
|
+
return {
|
|
57
|
+
status: 'verified',
|
|
58
|
+
seedWords: null,
|
|
59
|
+
identitySeed: state.identitySeed,
|
|
60
|
+
error: null,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (type === 'FAIL') {
|
|
64
|
+
return {
|
|
65
|
+
status: 'error',
|
|
66
|
+
seedWords: null,
|
|
67
|
+
identitySeed: null,
|
|
68
|
+
error: event.message,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (type === 'RESET') {
|
|
72
|
+
return initialIdentityFlowState;
|
|
73
|
+
}
|
|
74
|
+
console.warn(`Unexpected event ${type} in seed-generated`);
|
|
75
|
+
return state;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
case 'verified':
|
|
79
|
+
case 'imported': {
|
|
80
|
+
if (type === 'RESET') {
|
|
81
|
+
return initialIdentityFlowState;
|
|
82
|
+
}
|
|
83
|
+
if (type === 'FAIL') {
|
|
84
|
+
return {
|
|
85
|
+
status: 'error',
|
|
86
|
+
seedWords: null,
|
|
87
|
+
identitySeed: null,
|
|
88
|
+
error: event.message,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
console.warn(`Unexpected event ${type} in ${state.status}`);
|
|
92
|
+
return state;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
case 'error': {
|
|
96
|
+
if (type === 'RESET') {
|
|
97
|
+
return initialIdentityFlowState;
|
|
98
|
+
}
|
|
99
|
+
console.warn(`Unexpected event ${type} in error`);
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
default:
|
|
104
|
+
return state;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// packages/react/src/headless/import-flow.ts
|
|
2
|
+
import { validateSeedPhrase, type SeedStrength } from '@me2em/core';
|
|
3
|
+
|
|
4
|
+
export type ImportStatus = 'entering' | 'valid' | 'invalid';
|
|
5
|
+
|
|
6
|
+
export interface ImportState {
|
|
7
|
+
status: ImportStatus;
|
|
8
|
+
words: (string | null)[];
|
|
9
|
+
expectedCount: 12 | 24;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ImportEvent =
|
|
13
|
+
| { type: 'INIT'; expectedCount: 12 | 24 }
|
|
14
|
+
| { type: 'SET_WORD'; position: number; word: string }
|
|
15
|
+
| { type: 'CLEAR_WORD'; position: number }
|
|
16
|
+
| { type: 'CHECK' }
|
|
17
|
+
| { type: 'RESET' };
|
|
18
|
+
|
|
19
|
+
export function initImportState(expectedCount: 12 | 24): ImportState {
|
|
20
|
+
return {
|
|
21
|
+
status: 'entering',
|
|
22
|
+
words: Array(expectedCount).fill(null),
|
|
23
|
+
expectedCount,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function importFlowReducer(
|
|
28
|
+
state: ImportState,
|
|
29
|
+
event: ImportEvent
|
|
30
|
+
): ImportState {
|
|
31
|
+
const { type } = event;
|
|
32
|
+
|
|
33
|
+
if (type === 'INIT') {
|
|
34
|
+
return initImportState(event.expectedCount);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (type === 'RESET') {
|
|
38
|
+
return initImportState(state.expectedCount);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (type === 'SET_WORD') {
|
|
42
|
+
const { position, word } = event;
|
|
43
|
+
if (position < 0 || position >= state.expectedCount) {
|
|
44
|
+
console.warn(`SET_WORD: position ${position} out of bounds`);
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
const normalized = word.toLowerCase().trim();
|
|
48
|
+
const newWords = [...state.words] as (string | null)[];
|
|
49
|
+
newWords[position] = normalized;
|
|
50
|
+
return { ...state, words: newWords, status: 'entering' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (type === 'CLEAR_WORD') {
|
|
54
|
+
const { position } = event;
|
|
55
|
+
if (position < 0 || position >= state.expectedCount) {
|
|
56
|
+
console.warn(`CLEAR_WORD: position ${position} out of bounds`);
|
|
57
|
+
return state;
|
|
58
|
+
}
|
|
59
|
+
const newWords = [...state.words] as (string | null)[];
|
|
60
|
+
newWords[position] = null;
|
|
61
|
+
return { ...state, words: newWords, status: 'entering' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (type === 'CHECK') {
|
|
65
|
+
const { words } = state;
|
|
66
|
+
const nullIdx = words.findIndex((w) => w === null);
|
|
67
|
+
if (nullIdx !== -1) {
|
|
68
|
+
return {
|
|
69
|
+
...state,
|
|
70
|
+
status: 'invalid',
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const result = validateSeedPhrase(words as string[]);
|
|
74
|
+
return {
|
|
75
|
+
...state,
|
|
76
|
+
status: result.isValid ? 'valid' : 'invalid',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.warn(`Unexpected event ${type} in import-flow`);
|
|
81
|
+
return state;
|
|
82
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export { identityFlowReducer, initialIdentityFlowState } from './identity-flow.js';
|
|
2
|
+
export type {
|
|
3
|
+
IdentityFlowState,
|
|
4
|
+
IdentityFlowStatus,
|
|
5
|
+
IdentityFlowEvent,
|
|
6
|
+
} from './types.js';
|
|
7
|
+
export {
|
|
8
|
+
buildVerificationGrid,
|
|
9
|
+
isGridSelectionCorrect,
|
|
10
|
+
shuffleWithSeed,
|
|
11
|
+
} from './seed-words.js';
|
|
12
|
+
export {
|
|
13
|
+
displayFlowReducer,
|
|
14
|
+
initialDisplayState,
|
|
15
|
+
} from './display-flow.js';
|
|
16
|
+
export type {
|
|
17
|
+
DisplayState,
|
|
18
|
+
DisplayStatus,
|
|
19
|
+
DisplayEvent,
|
|
20
|
+
} from './display-flow.js';
|
|
21
|
+
export {
|
|
22
|
+
verifyFlowReducer,
|
|
23
|
+
initVerifyState,
|
|
24
|
+
} from './verify-flow.js';
|
|
25
|
+
export type {
|
|
26
|
+
VerifyState,
|
|
27
|
+
VerifyStatus,
|
|
28
|
+
VerifyEvent,
|
|
29
|
+
} from './verify-flow.js';
|
|
30
|
+
export {
|
|
31
|
+
importFlowReducer,
|
|
32
|
+
initImportState,
|
|
33
|
+
} from './import-flow.js';
|
|
34
|
+
export type {
|
|
35
|
+
ImportState,
|
|
36
|
+
ImportStatus,
|
|
37
|
+
ImportEvent,
|
|
38
|
+
} from './import-flow.js';
|
|
39
|
+
export {
|
|
40
|
+
IdentityContextIDBStorage,
|
|
41
|
+
} from './identity-context-storage.js';
|
|
42
|
+
export type {
|
|
43
|
+
IdentityContextData,
|
|
44
|
+
IdentityContextStorage,
|
|
45
|
+
} from './identity-context-types.js';
|
|
46
|
+
export {
|
|
47
|
+
deriveCacheKey,
|
|
48
|
+
encryptContext,
|
|
49
|
+
decryptContext,
|
|
50
|
+
} from './context-crypto.js';
|
|
51
|
+
export type {
|
|
52
|
+
ContextEnvelope,
|
|
53
|
+
} from './context-crypto.js';
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// packages/react/src/headless/seed-words.ts
|
|
2
|
+
|
|
3
|
+
export function shuffleWithSeed<T>(items: T[], seed: number): T[] {
|
|
4
|
+
const arr = [...items];
|
|
5
|
+
let currentSeed = seed;
|
|
6
|
+
function nextRand(): number {
|
|
7
|
+
currentSeed = (currentSeed * 1664525 + 1013904223) >>> 0;
|
|
8
|
+
return currentSeed;
|
|
9
|
+
}
|
|
10
|
+
for (let i = arr.length - 1; i > 0; i--) {
|
|
11
|
+
const j = nextRand() % (i + 1);
|
|
12
|
+
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
13
|
+
}
|
|
14
|
+
return arr;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function buildVerificationGrid(
|
|
18
|
+
realWords: string[],
|
|
19
|
+
gridSize: number,
|
|
20
|
+
randomSeed: number,
|
|
21
|
+
allWords: readonly string[]
|
|
22
|
+
): { words: string[]; correctIndices: number[] } {
|
|
23
|
+
if (gridSize < realWords.length) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
`gridSize (${gridSize}) must be >= realWords.length (${realWords.length})`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const shuffled = shuffleWithSeed(realWords, randomSeed);
|
|
30
|
+
const realSet = new Set(realWords);
|
|
31
|
+
const available = allWords.filter((w) => !realSet.has(w));
|
|
32
|
+
const shuffledDecoys = shuffleWithSeed(available, randomSeed + 1);
|
|
33
|
+
|
|
34
|
+
const needed = gridSize - realWords.length;
|
|
35
|
+
const decoys = shuffledDecoys.slice(0, needed);
|
|
36
|
+
|
|
37
|
+
const words = [...shuffled, ...decoys];
|
|
38
|
+
if (words.length !== gridSize) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`Cannot fill grid: wordlist too small (got ${words.length}, need ${gridSize})`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Map each word occurrence to a DISTINCT grid position, so
|
|
45
|
+
// repeated words in the phrase get their own cells.
|
|
46
|
+
const positions = new Map<string, number[]>();
|
|
47
|
+
words.forEach((w, i) => {
|
|
48
|
+
const list = positions.get(w);
|
|
49
|
+
if (list) list.push(i);
|
|
50
|
+
else positions.set(w, [i]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const correctIndices = realWords.map((w) => {
|
|
54
|
+
const list = positions.get(w);
|
|
55
|
+
if (!list || list.length === 0) {
|
|
56
|
+
throw new Error(`Internal error: word "${w}" missing from grid`);
|
|
57
|
+
}
|
|
58
|
+
return list.shift() as number;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return { words, correctIndices };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Selection is compared IN ORDER: selected[i] must equal
|
|
66
|
+
* correctIndices[i]. The i-th click must be the i-th word of the
|
|
67
|
+
* phrase. Repeated phrase words map to distinct grid cells.
|
|
68
|
+
*/
|
|
69
|
+
export function isGridSelectionCorrect(
|
|
70
|
+
grid: { words: string[]; correctIndices: number[] },
|
|
71
|
+
selected: number[]
|
|
72
|
+
): boolean {
|
|
73
|
+
if (selected.length !== grid.correctIndices.length) return false;
|
|
74
|
+
return selected.every((v, i) => v === grid.correctIndices[i]);
|
|
75
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type IdentityFlowStatus =
|
|
2
|
+
| 'idle'
|
|
3
|
+
| 'seed-generated'
|
|
4
|
+
| 'verified'
|
|
5
|
+
| 'ready'
|
|
6
|
+
| 'imported'
|
|
7
|
+
| 'error';
|
|
8
|
+
|
|
9
|
+
export interface IdentityFlowState {
|
|
10
|
+
status: IdentityFlowStatus;
|
|
11
|
+
seedWords: string[] | null;
|
|
12
|
+
identitySeed: Uint8Array | null;
|
|
13
|
+
error: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type IdentityFlowEvent =
|
|
17
|
+
| { type: 'GENERATE'; words: string[]; seed: Uint8Array }
|
|
18
|
+
| { type: 'IMPORT'; seed: Uint8Array }
|
|
19
|
+
| { type: 'CONFIRM_WORDS' }
|
|
20
|
+
| { type: 'RESET' }
|
|
21
|
+
| { type: 'FAIL'; message: string };
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// packages/react/src/headless/verify-flow.ts
|
|
2
|
+
import {
|
|
3
|
+
buildVerificationGrid,
|
|
4
|
+
isGridSelectionCorrect,
|
|
5
|
+
} from './seed-words.js';
|
|
6
|
+
|
|
7
|
+
export type VerifyStatus = 'collecting' | 'correct' | 'incorrect';
|
|
8
|
+
|
|
9
|
+
export interface VerifyState {
|
|
10
|
+
status: VerifyStatus;
|
|
11
|
+
grid: { words: string[]; correctIndices: number[] };
|
|
12
|
+
selected: number[];
|
|
13
|
+
attempts: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type VerifyEvent =
|
|
17
|
+
| { type: 'SELECT'; index: number }
|
|
18
|
+
| { type: 'DESELECT'; index: number }
|
|
19
|
+
| { type: 'CHECK' }
|
|
20
|
+
| {
|
|
21
|
+
type: 'RESET';
|
|
22
|
+
realWords: string[];
|
|
23
|
+
gridSize: number;
|
|
24
|
+
randomSeed: number;
|
|
25
|
+
allWords: readonly string[];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function initVerifyState(
|
|
29
|
+
realWords: string[],
|
|
30
|
+
gridSize: number,
|
|
31
|
+
randomSeed: number,
|
|
32
|
+
allWords: readonly string[]
|
|
33
|
+
): VerifyState {
|
|
34
|
+
const grid = buildVerificationGrid(realWords, gridSize, randomSeed, allWords);
|
|
35
|
+
return {
|
|
36
|
+
status: 'collecting',
|
|
37
|
+
grid,
|
|
38
|
+
selected: [],
|
|
39
|
+
attempts: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function verifyFlowReducer(
|
|
44
|
+
state: VerifyState,
|
|
45
|
+
event: VerifyEvent
|
|
46
|
+
): VerifyState {
|
|
47
|
+
const { type } = event;
|
|
48
|
+
|
|
49
|
+
if (type === 'RESET') {
|
|
50
|
+
return initVerifyState(
|
|
51
|
+
event.realWords,
|
|
52
|
+
event.gridSize,
|
|
53
|
+
event.randomSeed,
|
|
54
|
+
event.allWords
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Terminal states: only RESET allowed
|
|
59
|
+
if (state.status === 'correct' || state.status === 'incorrect') {
|
|
60
|
+
console.warn(
|
|
61
|
+
`Cannot ${type} in ${state.status} state — use RESET to retry`
|
|
62
|
+
);
|
|
63
|
+
return state;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// collecting state
|
|
67
|
+
if (type === 'SELECT') {
|
|
68
|
+
const { index } = event;
|
|
69
|
+
const { grid, selected } = state;
|
|
70
|
+
if (selected.includes(index)) {
|
|
71
|
+
console.warn(`Index ${index} already selected — use DESELECT`);
|
|
72
|
+
return state;
|
|
73
|
+
}
|
|
74
|
+
if (index < 0 || index >= grid.words.length) {
|
|
75
|
+
console.warn(`Index ${index} out of grid bounds`);
|
|
76
|
+
return state;
|
|
77
|
+
}
|
|
78
|
+
return { ...state, selected: [...selected, index] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (type === 'DESELECT') {
|
|
82
|
+
const { index } = event;
|
|
83
|
+
const idx = state.selected.indexOf(index);
|
|
84
|
+
if (idx === -1) {
|
|
85
|
+
console.warn(`Index ${index} not selected`);
|
|
86
|
+
return state;
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
...state,
|
|
90
|
+
selected: state.selected.filter((_, i) => i !== idx),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (type === 'CHECK') {
|
|
95
|
+
const { grid, selected, attempts } = state;
|
|
96
|
+
if (selected.length !== grid.correctIndices.length) {
|
|
97
|
+
console.warn(
|
|
98
|
+
`Cannot check: selected ${selected.length}, need ${grid.correctIndices.length}`
|
|
99
|
+
);
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
102
|
+
const correct = isGridSelectionCorrect(grid, selected);
|
|
103
|
+
return {
|
|
104
|
+
...state,
|
|
105
|
+
status: correct ? 'correct' : 'incorrect',
|
|
106
|
+
attempts: attempts + 1,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.warn(`Unexpected event ${type} in collecting`);
|
|
111
|
+
return state;
|
|
112
|
+
}
|
package/src/index.ts
ADDED