@ibgib/web-gib 0.0.59 → 0.0.61
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/AUTO-GENERATED-version.d.mts +1 -1
- package/dist/AUTO-GENERATED-version.mjs +1 -1
- package/dist/helpers.d.mts.map +1 -1
- package/dist/helpers.mjs +1 -0
- package/dist/helpers.mjs.map +1 -1
- package/dist/identity/device-delegate-helper.d.mts +43 -0
- package/dist/identity/device-delegate-helper.d.mts.map +1 -0
- package/dist/identity/device-delegate-helper.mjs +106 -0
- package/dist/identity/device-delegate-helper.mjs.map +1 -0
- package/dist/identity/device-delegate.respec.d.mts +2 -0
- package/dist/identity/device-delegate.respec.d.mts.map +1 -0
- package/dist/identity/device-delegate.respec.mjs +106 -0
- package/dist/identity/device-delegate.respec.mjs.map +1 -0
- package/dist/identity/identity-constants.mjs +2 -2
- package/dist/identity/identity-constants.mjs.map +1 -1
- package/dist/identity/identity-types.d.mts +4 -4
- package/dist/identity/identity-types.d.mts.map +1 -1
- package/dist/identity/keystone-sync-service.d.mts +18 -0
- package/dist/identity/keystone-sync-service.d.mts.map +1 -1
- package/dist/identity/keystone-sync-service.mjs +77 -1
- package/dist/identity/keystone-sync-service.mjs.map +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +1 -2
- package/dist/index.mjs.map +1 -1
- package/dist/storage/storage-helpers.web.d.mts +9 -0
- package/dist/storage/storage-helpers.web.d.mts.map +1 -1
- package/dist/storage/storage-helpers.web.mjs +42 -0
- package/dist/storage/storage-helpers.web.mjs.map +1 -1
- package/dist/ui/component/identity/add-device/add-device.css +93 -17
- package/dist/ui/component/identity/add-device/add-device.d.mts +24 -7
- package/dist/ui/component/identity/add-device/add-device.d.mts.map +1 -1
- package/dist/ui/component/identity/add-device/add-device.html +97 -23
- package/dist/ui/component/identity/add-device/add-device.mjs +489 -198
- package/dist/ui/component/identity/add-device/add-device.mjs.map +1 -1
- package/dist/ui/component/identity/keystone-creator/keystone-creator.d.mts.map +1 -1
- package/dist/ui/component/identity/keystone-creator/keystone-creator.mjs +48 -61
- package/dist/ui/component/identity/keystone-creator/keystone-creator.mjs.map +1 -1
- package/dist/ui/component/identity/keystone-details/keystone-details.css +1 -1
- package/dist/ui/component/identity/keystone-details/keystone-details.d.mts +11 -8
- package/dist/ui/component/identity/keystone-details/keystone-details.d.mts.map +1 -1
- package/dist/ui/component/identity/keystone-details/keystone-details.html +29 -20
- package/dist/ui/component/identity/keystone-details/keystone-details.mjs +358 -97
- package/dist/ui/component/identity/keystone-details/keystone-details.mjs.map +1 -1
- package/dist/ui/component/identity/remove-device/remove-device.d.mts +1 -1
- package/dist/ui/component/identity/remove-device/remove-device.d.mts.map +1 -1
- package/dist/ui/component/identity/remove-device/remove-device.html +3 -3
- package/dist/ui/component/identity/remove-device/remove-device.mjs +7 -7
- package/dist/ui/component/identity/remove-device/remove-device.mjs.map +1 -1
- package/dist/ui/ui-constants.d.mts +1 -0
- package/dist/ui/ui-constants.d.mts.map +1 -1
- package/dist/ui/ui-constants.mjs +1 -0
- package/dist/ui/ui-constants.mjs.map +1 -1
- package/dist/ui/ui-types.d.mts +5 -0
- package/dist/ui/ui-types.d.mts.map +1 -1
- package/dist/util/device-helper.d.mts +30 -0
- package/dist/util/device-helper.d.mts.map +1 -0
- package/dist/util/device-helper.mjs +90 -0
- package/dist/util/device-helper.mjs.map +1 -0
- package/dist/util/device-helper.respec.d.mts +2 -0
- package/dist/util/device-helper.respec.d.mts.map +1 -0
- package/dist/util/device-helper.respec.mjs +83 -0
- package/dist/util/device-helper.respec.mjs.map +1 -0
- package/package.json +3 -3
- package/src/AUTO-GENERATED-version.mts +1 -1
- package/src/helpers.mts +1 -0
- package/src/identity/custodian-delegate-helper.mts.bak +7 -0
- package/src/identity/device-delegate-helper.mts +135 -0
- package/src/identity/device-delegate.respec.mts +125 -0
- package/src/identity/identity-constants.mts +2 -2
- package/src/identity/identity-types.mts +4 -4
- package/src/identity/keystone-sync-service.mts +95 -2
- package/src/index.mts +1 -2
- package/src/storage/storage-helpers.web.mts +47 -0
- package/src/ui/component/identity/add-device/add-device.css +93 -17
- package/src/ui/component/identity/add-device/add-device.html +97 -23
- package/src/ui/component/identity/add-device/add-device.mts +579 -208
- package/src/ui/component/identity/keystone-creator/keystone-creator.mts +55 -69
- package/src/ui/component/identity/keystone-details/keystone-details.css +1 -1
- package/src/ui/component/identity/keystone-details/keystone-details.html +29 -20
- package/src/ui/component/identity/keystone-details/keystone-details.mts +374 -106
- package/src/ui/component/identity/remove-device/remove-device.html +3 -3
- package/src/ui/component/identity/remove-device/remove-device.mts +8 -8
- package/src/ui/ui-constants.mts +1 -0
- package/src/ui/ui-types.mts +6 -0
- package/src/util/device-helper.mts +104 -0
- package/src/util/device-helper.respec.mts +90 -0
- package/dist/identity/custodian-delegate-helper.d.mts +0 -22
- package/dist/identity/custodian-delegate-helper.d.mts.map +0 -1
- package/dist/identity/custodian-delegate-helper.mjs +0 -50
- package/dist/identity/custodian-delegate-helper.mjs.map +0 -1
- package/src/identity/custodian-delegate-helper.mts +0 -61
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { respecfully, iReckon, ifWe // DO NOT REMOVE ifWeMight or ifWe even if no longer used. We constantly change these when focusing tests
|
|
2
|
+
} from '@ibgib/helper-gib/dist/respec-gib/respec-gib.mjs';
|
|
3
|
+
const maam = `[${import.meta.url}]`, sir = maam;
|
|
4
|
+
import { extractErrorMsg } from '@ibgib/helper-gib/dist/helpers/utils-helper.mjs';
|
|
5
|
+
import { generateDefaultDeviceTag } from './device-helper.mjs';
|
|
6
|
+
await respecfully(sir, 'device-helper', async () => {
|
|
7
|
+
await ifWe(sir, 'generate clean tag for Desktop Windows Chrome', async () => {
|
|
8
|
+
const lc = `[generate clean tag for Desktop Windows Chrome]`;
|
|
9
|
+
try {
|
|
10
|
+
const tag = generateDefaultDeviceTag({
|
|
11
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
12
|
+
userAgentData: { platform: 'Windows', mobile: false },
|
|
13
|
+
randomSuffix: 'a1b2'
|
|
14
|
+
});
|
|
15
|
+
iReckon(sir, tag).asTo('tag value').isGonnaBe('chrome-win-a1b2');
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
await ifWe(sir, 'generate clean tag for Mobile iOS Safari (iPhone)', async () => {
|
|
23
|
+
const lc = `[generate clean tag for Mobile iOS Safari (iPhone)]`;
|
|
24
|
+
try {
|
|
25
|
+
const tag = generateDefaultDeviceTag({
|
|
26
|
+
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
|
27
|
+
userAgentData: { platform: 'iOS', mobile: true },
|
|
28
|
+
randomSuffix: '3c4d'
|
|
29
|
+
});
|
|
30
|
+
iReckon(sir, tag).asTo('tag value').isGonnaBe('mobile-safari-ios-3c4d');
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
await ifWe(sir, 'generate clean tag for Android Chrome Phone', async () => {
|
|
38
|
+
const lc = `[generate clean tag for Android Chrome Phone]`;
|
|
39
|
+
try {
|
|
40
|
+
const tag = generateDefaultDeviceTag({
|
|
41
|
+
userAgent: 'Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36',
|
|
42
|
+
userAgentData: { platform: 'Android', mobile: true },
|
|
43
|
+
randomSuffix: '9e8f'
|
|
44
|
+
});
|
|
45
|
+
iReckon(sir, tag).asTo('tag value').isGonnaBe('mobile-chrome-android-9e8f');
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
await ifWe(sir, 'generate clean tag for iPad / Tablet', async () => {
|
|
53
|
+
const lc = `[generate clean tag for iPad / Tablet]`;
|
|
54
|
+
try {
|
|
55
|
+
const tag = generateDefaultDeviceTag({
|
|
56
|
+
userAgent: 'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
|
57
|
+
maxTouchPoints: 5,
|
|
58
|
+
screenWidth: 1024,
|
|
59
|
+
randomSuffix: '7a8b'
|
|
60
|
+
});
|
|
61
|
+
iReckon(sir, tag).asTo('tag value').isGonnaBe('tablet-safari-ios-7a8b');
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
await ifWe(sir, 'generate fallback tag with random 4-char suffix when no hints are present', async () => {
|
|
69
|
+
const lc = `[generate fallback tag with random 4-char suffix]`;
|
|
70
|
+
try {
|
|
71
|
+
const tag = generateDefaultDeviceTag({
|
|
72
|
+
userAgent: '',
|
|
73
|
+
randomSuffix: '1234'
|
|
74
|
+
});
|
|
75
|
+
iReckon(sir, tag).asTo('tag value').isGonnaBe('device-1234');
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
//# sourceMappingURL=device-helper.respec.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"device-helper.respec.mjs","sourceRoot":"","sources":["../../src/util/device-helper.respec.mts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ibgib/web-gib",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.61",
|
|
4
4
|
"description": "Framework for creating agentic ibGib web apps. Contains plumbing for ibgib components, agentic framework (currently only Gemini implemented), web-based IndexedDB storage substrate, and more.",
|
|
5
5
|
"funding": {
|
|
6
6
|
"type": "individual",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"license": "ISC",
|
|
30
30
|
"dependencies": {
|
|
31
31
|
"@google/genai": "^1.33.0",
|
|
32
|
-
"@ibgib/core-gib": "^0.1.
|
|
32
|
+
"@ibgib/core-gib": "^0.1.75",
|
|
33
33
|
"@ibgib/encrypt-gib": "^0.3.1",
|
|
34
34
|
"@ibgib/helper-gib": "^0.0.37",
|
|
35
|
-
"@ibgib/ts-gib": "^0.5.
|
|
35
|
+
"@ibgib/ts-gib": "^0.5.34"
|
|
36
36
|
},
|
|
37
37
|
"engines": {
|
|
38
38
|
"node": ">=19.0.0"
|
package/src/helpers.mts
CHANGED
|
@@ -716,6 +716,7 @@ export function getIbGibGlobalThis_IbGib(): IbGibGlobalThisInfo {
|
|
|
716
716
|
if (!result) {
|
|
717
717
|
console.log(`${lc} initializing globalThis.${GLOBAL_THIS_IBGIB_KEY}... (I: 9d9ff1813448d9a1b82b0e5790a74825)`);
|
|
718
718
|
result = {};
|
|
719
|
+
globalThis[GLOBAL_THIS_IBGIB_KEY] = result;
|
|
719
720
|
}
|
|
720
721
|
|
|
721
722
|
return result;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module identity/device-delegate-helper
|
|
3
|
+
*
|
|
4
|
+
* Client-side helper functions for deriving, retrieving, persisting, and clearing local
|
|
5
|
+
* cryptographic device delegate signing tokens ($S_D$) in IndexedDB.
|
|
6
|
+
*
|
|
7
|
+
* Applicable symmetrically to Sovereign, Custodial, and Hybrid identities.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { extractErrorMsg } from "@ibgib/helper-gib/dist/helpers/utils-helper.mjs";
|
|
11
|
+
import { deriveDeviceDelegateSecret } from "@ibgib/core-gib/dist/keystone/delegate/delegate-helpers.mjs";
|
|
12
|
+
import { storageGet, storagePut, storageDelete } from "../storage/storage-helpers.web.mjs";
|
|
13
|
+
import { getGlobalDbName, getGlobalStoreName } from "../helpers.web.mjs";
|
|
14
|
+
|
|
15
|
+
export const DEVICE_DELEGATE_SECRET_PREFIX = 'ibgib_device_delegate_secret_';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Builds the IndexedDB storage key for a device delegate secret given its primary identity TJP address.
|
|
19
|
+
*/
|
|
20
|
+
export function getDeviceDelegateSecretKey({ primaryTjpAddr }: { primaryTjpAddr: string }): string {
|
|
21
|
+
if (!primaryTjpAddr) {
|
|
22
|
+
throw new Error('primaryTjpAddr is required to get device delegate secret key (E: 1a2b3c4d5e6f7890123456789a000101)');
|
|
23
|
+
}
|
|
24
|
+
return `${DEVICE_DELEGATE_SECRET_PREFIX}${primaryTjpAddr}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Retrieves the stored device delegate secret for the given primary identity from IndexedDB, if present.
|
|
29
|
+
*/
|
|
30
|
+
export async function getDeviceDelegateSecret({
|
|
31
|
+
primaryTjpAddr,
|
|
32
|
+
}: {
|
|
33
|
+
primaryTjpAddr: string;
|
|
34
|
+
}): Promise<string | undefined> {
|
|
35
|
+
const lc = `[${getDeviceDelegateSecret.name}]`;
|
|
36
|
+
try {
|
|
37
|
+
if (!primaryTjpAddr) {
|
|
38
|
+
throw new Error('primaryTjpAddr is required to get device delegate secret (E: 2a2b3c4d5e6f7890123456789a000102)');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const dbName = getGlobalDbName();
|
|
42
|
+
const storeName = getGlobalStoreName();
|
|
43
|
+
const key = getDeviceDelegateSecretKey({ primaryTjpAddr });
|
|
44
|
+
|
|
45
|
+
return await storageGet({ dbName, storeName, key });
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Persists a device delegate secret for the given primary identity into IndexedDB.
|
|
54
|
+
*/
|
|
55
|
+
export async function setDeviceDelegateSecret({
|
|
56
|
+
primaryTjpAddr,
|
|
57
|
+
delegateSecret,
|
|
58
|
+
}: {
|
|
59
|
+
primaryTjpAddr: string;
|
|
60
|
+
delegateSecret: string;
|
|
61
|
+
}): Promise<void> {
|
|
62
|
+
const lc = `[${setDeviceDelegateSecret.name}]`;
|
|
63
|
+
try {
|
|
64
|
+
if (!primaryTjpAddr) {
|
|
65
|
+
throw new Error('primaryTjpAddr is required to set device delegate secret (E: 3a2b3c4d5e6f7890123456789a000103)');
|
|
66
|
+
}
|
|
67
|
+
if (!delegateSecret) {
|
|
68
|
+
throw new Error('delegateSecret is required to set device delegate secret (E: 4a2b3c4d5e6f7890123456789a000104)');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const dbName = getGlobalDbName();
|
|
72
|
+
const storeName = getGlobalStoreName();
|
|
73
|
+
const key = getDeviceDelegateSecretKey({ primaryTjpAddr });
|
|
74
|
+
|
|
75
|
+
await storagePut({ dbName, storeName, key, value: delegateSecret });
|
|
76
|
+
} catch (error) {
|
|
77
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Retrieves an existing device delegate secret from IndexedDB, or generates a high-entropy 512-bit
|
|
84
|
+
* secret, persists it, and returns it.
|
|
85
|
+
*/
|
|
86
|
+
export async function getOrCreateDeviceDelegateSecret({
|
|
87
|
+
primaryTjpAddr,
|
|
88
|
+
deviceTag,
|
|
89
|
+
}: {
|
|
90
|
+
primaryTjpAddr: string;
|
|
91
|
+
deviceTag?: string;
|
|
92
|
+
}): Promise<string> {
|
|
93
|
+
const lc = `[${getOrCreateDeviceDelegateSecret.name}]`;
|
|
94
|
+
try {
|
|
95
|
+
if (!primaryTjpAddr) {
|
|
96
|
+
throw new Error('primaryTjpAddr is required to get or create device delegate secret (E: 5a2b3c4d5e6f7890123456789a000105)');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const existingSecret = await getDeviceDelegateSecret({ primaryTjpAddr });
|
|
100
|
+
if (existingSecret) {
|
|
101
|
+
return existingSecret;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const newSecret = await deriveDeviceDelegateSecret({ primaryTjpAddr, deviceTag });
|
|
105
|
+
await setDeviceDelegateSecret({ primaryTjpAddr, delegateSecret: newSecret });
|
|
106
|
+
|
|
107
|
+
return newSecret;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Removes the stored local device delegate secret from IndexedDB.
|
|
116
|
+
*/
|
|
117
|
+
export async function clearDeviceDelegateSecret({
|
|
118
|
+
primaryTjpAddr,
|
|
119
|
+
}: {
|
|
120
|
+
primaryTjpAddr: string;
|
|
121
|
+
}): Promise<void> {
|
|
122
|
+
const lc = `[${clearDeviceDelegateSecret.name}]`;
|
|
123
|
+
try {
|
|
124
|
+
if (!primaryTjpAddr) { return; }
|
|
125
|
+
|
|
126
|
+
const dbName = getGlobalDbName();
|
|
127
|
+
const storeName = getGlobalStoreName();
|
|
128
|
+
const key = getDeviceDelegateSecretKey({ primaryTjpAddr });
|
|
129
|
+
|
|
130
|
+
await storageDelete({ dbName, storeName, key });
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { respecfully, ifWeMight, iReckon } from '@ibgib/helper-gib/dist/respec-gib/respec-gib.mjs';
|
|
2
|
+
import { getIbGibGlobalThis_IbGib } from '../helpers.mjs';
|
|
3
|
+
import {
|
|
4
|
+
getDeviceDelegateSecretKey,
|
|
5
|
+
getDeviceDelegateSecret,
|
|
6
|
+
setDeviceDelegateSecret,
|
|
7
|
+
getOrCreateDeviceDelegateSecret,
|
|
8
|
+
clearDeviceDelegateSecret,
|
|
9
|
+
DEVICE_DELEGATE_SECRET_PREFIX
|
|
10
|
+
} from './device-delegate-helper.mjs';
|
|
11
|
+
|
|
12
|
+
const sir = `[${import.meta.url}]`;
|
|
13
|
+
|
|
14
|
+
// Initialize global db & store names for tests
|
|
15
|
+
const globalThisIbGib = getIbGibGlobalThis_IbGib();
|
|
16
|
+
globalThisIbGib.dbName_hack = 'space_gib_db';
|
|
17
|
+
globalThisIbGib.storeName_hack = 'space_gib_store';
|
|
18
|
+
|
|
19
|
+
// Mock storage substrate for Node test environment
|
|
20
|
+
const inMemoryStore = new Map<string, string>();
|
|
21
|
+
|
|
22
|
+
if (typeof globalThis.indexedDB === 'undefined') {
|
|
23
|
+
(globalThis as any).indexedDB = {
|
|
24
|
+
open: (dbName: string) => {
|
|
25
|
+
const req: any = {};
|
|
26
|
+
setTimeout(() => {
|
|
27
|
+
const db = {
|
|
28
|
+
objectStoreNames: { contains: () => true },
|
|
29
|
+
transaction: (storeName: string) => ({
|
|
30
|
+
objectStore: (sName: string) => ({
|
|
31
|
+
get: (key: string) => {
|
|
32
|
+
const getReq: any = {};
|
|
33
|
+
setTimeout(() => {
|
|
34
|
+
const val = inMemoryStore.get(key);
|
|
35
|
+
getReq.result = val !== undefined ? { value: val } : undefined;
|
|
36
|
+
if (getReq.onsuccess) getReq.onsuccess({ target: getReq });
|
|
37
|
+
}, 0);
|
|
38
|
+
return getReq;
|
|
39
|
+
},
|
|
40
|
+
put: (datum: any) => {
|
|
41
|
+
const putReq: any = {};
|
|
42
|
+
setTimeout(() => {
|
|
43
|
+
inMemoryStore.set(datum.key, datum.value);
|
|
44
|
+
if (putReq.onsuccess) putReq.onsuccess({ target: putReq });
|
|
45
|
+
}, 0);
|
|
46
|
+
return putReq;
|
|
47
|
+
},
|
|
48
|
+
delete: (key: string) => {
|
|
49
|
+
const delReq: any = {};
|
|
50
|
+
setTimeout(() => {
|
|
51
|
+
inMemoryStore.delete(key);
|
|
52
|
+
if (delReq.onsuccess) delReq.onsuccess({ target: delReq });
|
|
53
|
+
}, 0);
|
|
54
|
+
return delReq;
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
}),
|
|
58
|
+
close: () => {}
|
|
59
|
+
};
|
|
60
|
+
req.result = db;
|
|
61
|
+
if (req.onsuccess) req.onsuccess({ target: req });
|
|
62
|
+
}, 0);
|
|
63
|
+
return req;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await respecfully(sir, 'Device Delegate Storage & Secret Lifecycle', async () => {
|
|
69
|
+
|
|
70
|
+
await ifWeMight(sir, 'getDeviceDelegateSecretKey constructs deterministic storage key', async () => {
|
|
71
|
+
const primaryTjpAddr = 'keystone^alice_tjp_12345';
|
|
72
|
+
const key = getDeviceDelegateSecretKey({ primaryTjpAddr });
|
|
73
|
+
|
|
74
|
+
iReckon(sir, key).willEqual(`${DEVICE_DELEGATE_SECRET_PREFIX}${primaryTjpAddr}`);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await ifWeMight(sir, 'setDeviceDelegateSecret and getDeviceDelegateSecret persist and retrieve secret', async () => {
|
|
78
|
+
const primaryTjpAddr = 'keystone^bob_tjp_999';
|
|
79
|
+
const secret = 'super-secret-512-token-abc';
|
|
80
|
+
|
|
81
|
+
await setDeviceDelegateSecret({ primaryTjpAddr, delegateSecret: secret });
|
|
82
|
+
const retrieved = await getDeviceDelegateSecret({ primaryTjpAddr });
|
|
83
|
+
|
|
84
|
+
iReckon(sir, retrieved).willEqual(secret);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await ifWeMight(sir, 'getOrCreateDeviceDelegateSecret generates high entropy token on first call and caches it', async () => {
|
|
88
|
+
const primaryTjpAddr = 'keystone^charlie_tjp_888';
|
|
89
|
+
|
|
90
|
+
const token1 = await getOrCreateDeviceDelegateSecret({ primaryTjpAddr, deviceTag: 'laptop' });
|
|
91
|
+
iReckon(sir, typeof token1 === 'string' && token1.length === 128).willEqual(true);
|
|
92
|
+
|
|
93
|
+
const token2 = await getOrCreateDeviceDelegateSecret({ primaryTjpAddr, deviceTag: 'laptop' });
|
|
94
|
+
iReckon(sir, token1).willEqual(token2);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
await ifWeMight(sir, 'clearDeviceDelegateSecret removes stored secret', async () => {
|
|
98
|
+
const primaryTjpAddr = 'keystone^david_tjp_777';
|
|
99
|
+
|
|
100
|
+
await getOrCreateDeviceDelegateSecret({ primaryTjpAddr });
|
|
101
|
+
const beforeClear = await getDeviceDelegateSecret({ primaryTjpAddr });
|
|
102
|
+
iReckon(sir, !!beforeClear).willEqual(true);
|
|
103
|
+
|
|
104
|
+
await clearDeviceDelegateSecret({ primaryTjpAddr });
|
|
105
|
+
const afterClear = await getDeviceDelegateSecret({ primaryTjpAddr });
|
|
106
|
+
iReckon(sir, afterClear).willEqual(undefined);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await ifWeMight(sir, 'fails fast when global db or store name is uninitialized', async () => {
|
|
110
|
+
const primaryTjpAddr = 'keystone^failfast_tjp_000';
|
|
111
|
+
const savedDb = globalThisIbGib.dbName_hack;
|
|
112
|
+
globalThisIbGib.dbName_hack = undefined as any;
|
|
113
|
+
|
|
114
|
+
let threw = false;
|
|
115
|
+
try {
|
|
116
|
+
await getDeviceDelegateSecret({ primaryTjpAddr });
|
|
117
|
+
} catch (error) {
|
|
118
|
+
threw = true;
|
|
119
|
+
} finally {
|
|
120
|
+
globalThisIbGib.dbName_hack = savedDb;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
iReckon(sir, threw).willEqual(true);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -65,8 +65,8 @@ export const IDENTITY_POOL_ID_APP_EPOCH = 'identity-app-epoch';
|
|
|
65
65
|
|
|
66
66
|
// ===========================================================================
|
|
67
67
|
// KDF SALTS
|
|
68
|
-
// Used to derive tier-specific MasterSecrets from a root
|
|
69
|
-
// Different salt per tier ensures independent secrets even with same
|
|
68
|
+
// Used to derive tier-specific MasterSecrets from a root password.
|
|
69
|
+
// Different salt per tier ensures independent secrets even with same password.
|
|
70
70
|
// ===========================================================================
|
|
71
71
|
|
|
72
72
|
export const IDENTITY_KDF_SALT_META = 'ibgib-identity-meta-v1';
|
|
@@ -221,17 +221,17 @@ export interface IIdentityService {
|
|
|
221
221
|
// ===========================================================================
|
|
222
222
|
|
|
223
223
|
/**
|
|
224
|
-
* Inputs for deriving a tier-specific MasterSecret from a root
|
|
224
|
+
* Inputs for deriving a tier-specific MasterSecret from a root password.
|
|
225
225
|
*
|
|
226
226
|
* Each tier uses a different salt to ensure independent MasterSecrets
|
|
227
|
-
* even when the same
|
|
227
|
+
* even when the same password is used for all tiers.
|
|
228
228
|
*
|
|
229
229
|
* @see docs/identity/KEYSTONES.md for per-tier salt conventions
|
|
230
230
|
* @see identity-helpers.mts for derivation functions
|
|
231
231
|
*/
|
|
232
232
|
export interface MasterSecretDerivationContext {
|
|
233
|
-
/** The root
|
|
234
|
-
|
|
233
|
+
/** The root password (sovereign path) or server secret (custodial path). */
|
|
234
|
+
password: string;
|
|
235
235
|
|
|
236
236
|
/**
|
|
237
237
|
* Tier-specific salt (e.g., `IDENTITY_KDF_SALT_META`, `IDENTITY_KDF_SALT_LOCAL`).
|
|
@@ -9,12 +9,15 @@ import { extractErrorMsg } from "@ibgib/helper-gib/dist/helpers/utils-helper.mjs
|
|
|
9
9
|
import { getIbAndGib, getIbGibAddr } from "@ibgib/ts-gib/dist/helper.mjs";
|
|
10
10
|
import { getGibInfo } from "@ibgib/ts-gib/dist/V1/transforms/transform-helper.mjs";
|
|
11
11
|
import { KeystoneIbGib_V1 } from "@ibgib/core-gib/dist/keystone/keystone-types.mjs";
|
|
12
|
+
import { KeystoneService_V1 } from "@ibgib/core-gib/dist/keystone/keystone-service-v1.mjs";
|
|
12
13
|
import { getTimelinesGroupedByTjp, getTjpAddr, toDto } from "@ibgib/core-gib/dist/common/other/ibgib-helper.mjs";
|
|
13
14
|
import { updateSpecialIndex } from "@ibgib/core-gib/dist/timeline/timeline-api.mjs";
|
|
15
|
+
import { isDelegateExpired } from "@ibgib/core-gib/dist/keystone/delegate/delegate-helpers.mjs";
|
|
14
16
|
import { getApiBridge } from "../api/ibgib-api-bridge.mjs";
|
|
15
17
|
import { getGlobalMetaspace_waitIfNeeded } from "../helpers.mjs";
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
+
import { getDeviceDelegateSecret } from "./device-delegate-helper.mjs";
|
|
19
|
+
import { EVENT_IBGIB_IDENTITY_UPDATE_AVAILABLE, EVENT_IBGIB_IDENTITY_RENEWAL_REQUIRED } from "../ui/ui-constants.mjs";
|
|
20
|
+
import { EventIbGibIdentityUpdateAvailableDetail, EventIbGibIdentityRenewalRequiredDetail } from "../ui/ui-types.mjs";
|
|
18
21
|
|
|
19
22
|
export interface KeystoneUpdateCheckResult {
|
|
20
23
|
hasUpdate: boolean;
|
|
@@ -37,6 +40,96 @@ export class KeystoneSyncService {
|
|
|
37
40
|
}
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
private dispatchRenewalRequiredEvent(detail: EventIbGibIdentityRenewalRequiredDetail): void {
|
|
44
|
+
if (typeof window !== 'undefined') {
|
|
45
|
+
window.dispatchEvent(new CustomEvent(EVENT_IBGIB_IDENTITY_RENEWAL_REQUIRED, {
|
|
46
|
+
detail,
|
|
47
|
+
bubbles: true,
|
|
48
|
+
composed: true,
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Retrieves the stored device delegate secret ($S_D$) for an active primary identity from IndexedDB.
|
|
55
|
+
*/
|
|
56
|
+
public async getDeviceDelegateSecret({
|
|
57
|
+
primaryTjpAddr
|
|
58
|
+
}: {
|
|
59
|
+
primaryTjpAddr: string;
|
|
60
|
+
}): Promise<string | undefined> {
|
|
61
|
+
return getDeviceDelegateSecret({ primaryTjpAddr });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Checks if the active identity has a valid, unexpired sync delegate on this device.
|
|
66
|
+
*/
|
|
67
|
+
public async checkDelegateStatus({
|
|
68
|
+
activePrimaryAddr,
|
|
69
|
+
metaspace,
|
|
70
|
+
space
|
|
71
|
+
}: {
|
|
72
|
+
activePrimaryAddr: string;
|
|
73
|
+
metaspace?: any;
|
|
74
|
+
space?: any;
|
|
75
|
+
}): Promise<{ isValid: boolean; reason?: 'expired' | 'missing_secret' }> {
|
|
76
|
+
const lc = `${this.lc}[${this.checkDelegateStatus.name}]`;
|
|
77
|
+
try {
|
|
78
|
+
if (!metaspace) metaspace = await getGlobalMetaspace_waitIfNeeded();
|
|
79
|
+
if (!metaspace) {
|
|
80
|
+
throw new Error(`${lc} (UNEXPECTED) metaspace falsy? (E: a1b2c3d4e5f60718293a4b5c6d7e8f90)`);
|
|
81
|
+
}
|
|
82
|
+
if (!space) space = await metaspace.getLocalUserSpace({ lock: false });
|
|
83
|
+
if (!space) {
|
|
84
|
+
throw new Error(`${lc} (UNEXPECTED) space falsy? (E: b2c3d4e5f60718293a4b5c6d7e8f90a1)`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const info = getGibInfo({ ibGibAddr: activePrimaryAddr });
|
|
88
|
+
const { ib } = getIbAndGib({ ibGibAddr: activePrimaryAddr });
|
|
89
|
+
const primaryTjpAddr = info.tjpGib ? `${ib}^${info.tjpGib}` : activePrimaryAddr;
|
|
90
|
+
|
|
91
|
+
// 1. Check if local secret exists in IndexedDB
|
|
92
|
+
const secret = await getDeviceDelegateSecret({ primaryTjpAddr });
|
|
93
|
+
if (!secret) {
|
|
94
|
+
this.dispatchRenewalRequiredEvent({
|
|
95
|
+
activePrimaryAddr,
|
|
96
|
+
primaryTjpAddr,
|
|
97
|
+
reason: 'missing_secret'
|
|
98
|
+
});
|
|
99
|
+
return { isValid: false, reason: 'missing_secret' };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 2. Check if primary keystone has registered delegates and if any are expired
|
|
103
|
+
const keystoneService = new KeystoneService_V1();
|
|
104
|
+
const keystone = await keystoneService.getLatestKeystone({
|
|
105
|
+
addr: activePrimaryAddr,
|
|
106
|
+
metaspace,
|
|
107
|
+
space
|
|
108
|
+
});
|
|
109
|
+
if (!keystone) {
|
|
110
|
+
throw new Error(`${lc} (UNEXPECTED) latest primary keystone not found for addr: ${activePrimaryAddr} (E: c3d4e5f60718293a4b5c6d7e8f90a1b2)`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (keystone?.data?.delegates) {
|
|
114
|
+
const delegates = Object.values(keystone.data.delegates);
|
|
115
|
+
const isAnyExpired = delegates.some(d => isDelegateExpired({ delegateInfo: d }));
|
|
116
|
+
if (isAnyExpired) {
|
|
117
|
+
this.dispatchRenewalRequiredEvent({
|
|
118
|
+
activePrimaryAddr,
|
|
119
|
+
primaryTjpAddr,
|
|
120
|
+
reason: 'expired'
|
|
121
|
+
});
|
|
122
|
+
return { isValid: false, reason: 'expired' };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { isValid: true };
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
129
|
+
return { isValid: false };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
40
133
|
/**
|
|
41
134
|
* Checks if a server update exists for the given active primary keystone address.
|
|
42
135
|
*/
|
package/src/index.mts
CHANGED
|
@@ -18,8 +18,7 @@ export * from "./api/ibgib-api-bridge.mjs";
|
|
|
18
18
|
|
|
19
19
|
// Identity & Sync services
|
|
20
20
|
export * from "./identity/keystone-sync-service.mjs";
|
|
21
|
-
export * from "./identity/
|
|
22
|
-
// export * from "./identity/sso-callback-helper.mjs";
|
|
21
|
+
export * from "./identity/device-delegate-helper.mjs";
|
|
23
22
|
export * from "./identity/sso-popup-helper.mjs";
|
|
24
23
|
|
|
25
24
|
// Identity UI components
|
|
@@ -726,3 +726,50 @@ export async function storageReaddir({
|
|
|
726
726
|
}
|
|
727
727
|
}
|
|
728
728
|
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* Deletes a single key from indexeddb storage.
|
|
732
|
+
*/
|
|
733
|
+
export async function storageDelete({
|
|
734
|
+
dbName,
|
|
735
|
+
storeName,
|
|
736
|
+
key,
|
|
737
|
+
logalot = false,
|
|
738
|
+
}: {
|
|
739
|
+
dbName: string;
|
|
740
|
+
storeName: string;
|
|
741
|
+
key: string;
|
|
742
|
+
logalot?: boolean;
|
|
743
|
+
}): Promise<void> {
|
|
744
|
+
const lc = `[${storageDelete.name}]`;
|
|
745
|
+
let db: IDBDatabase | undefined = undefined;
|
|
746
|
+
try {
|
|
747
|
+
if (logalot) { console.log(`${lc} starting... (I: 8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d)`); }
|
|
748
|
+
await new Promise<void>((resolve, reject) => {
|
|
749
|
+
const openDBRequest = indexedDB.open(dbName);
|
|
750
|
+
openDBRequest.onerror = () => {
|
|
751
|
+
reject(openDBRequest.error);
|
|
752
|
+
};
|
|
753
|
+
openDBRequest.onsuccess = () => {
|
|
754
|
+
db = openDBRequest.result;
|
|
755
|
+
const transaction = db.transaction(storeName, "readwrite");
|
|
756
|
+
const objectStore = transaction.objectStore(storeName);
|
|
757
|
+
const deleteRequest = objectStore.delete(key);
|
|
758
|
+
deleteRequest.onerror = () => {
|
|
759
|
+
reject(deleteRequest.error);
|
|
760
|
+
};
|
|
761
|
+
deleteRequest.onsuccess = () => {
|
|
762
|
+
resolve();
|
|
763
|
+
};
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
} catch (error) {
|
|
767
|
+
console.error(`${lc} ${extractErrorMsg(error)}`);
|
|
768
|
+
throw error;
|
|
769
|
+
} finally {
|
|
770
|
+
if (db) {
|
|
771
|
+
(db as any).close();
|
|
772
|
+
}
|
|
773
|
+
if (logalot) { console.log(`${lc} complete.`); }
|
|
774
|
+
}
|
|
775
|
+
}
|