@yume-chan/adb-credential-web 2.1.0 → 3.0.0-beta.0
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 +1 -1
- package/README.md +4 -1
- package/esm/index.d.ts +2 -23
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +2 -102
- package/esm/index.js.map +1 -1
- package/esm/manager.d.ts +9 -0
- package/esm/manager.d.ts.map +1 -0
- package/esm/manager.js +56 -0
- package/esm/manager.js.map +1 -0
- package/esm/storage/index.d.ts +6 -0
- package/esm/storage/index.d.ts.map +1 -0
- package/esm/storage/index.js +6 -0
- package/esm/storage/index.js.map +1 -0
- package/esm/storage/indexed-db/index.d.ts +3 -0
- package/esm/storage/indexed-db/index.d.ts.map +1 -0
- package/esm/storage/indexed-db/index.js +3 -0
- package/esm/storage/indexed-db/index.js.map +1 -0
- package/esm/storage/indexed-db/shared.d.ts +4 -0
- package/esm/storage/indexed-db/shared.d.ts.map +1 -0
- package/esm/storage/indexed-db/shared.js +50 -0
- package/esm/storage/indexed-db/shared.js.map +1 -0
- package/esm/storage/indexed-db/v1.d.ts +5 -0
- package/esm/storage/indexed-db/v1.d.ts.map +1 -0
- package/esm/storage/indexed-db/v1.js +22 -0
- package/esm/storage/indexed-db/v1.js.map +1 -0
- package/esm/storage/indexed-db/v2.d.ts +14 -0
- package/esm/storage/indexed-db/v2.d.ts.map +1 -0
- package/esm/storage/indexed-db/v2.js +78 -0
- package/esm/storage/indexed-db/v2.js.map +1 -0
- package/esm/storage/local-storage.d.ts +8 -0
- package/esm/storage/local-storage.d.ts.map +1 -0
- package/esm/storage/local-storage.js +25 -0
- package/esm/storage/local-storage.js.map +1 -0
- package/esm/storage/password.d.ts +27 -0
- package/esm/storage/password.d.ts.map +1 -0
- package/esm/storage/password.js +129 -0
- package/esm/storage/password.js.map +1 -0
- package/esm/storage/prf/index.d.ts +4 -0
- package/esm/storage/prf/index.d.ts.map +1 -0
- package/esm/storage/prf/index.js +4 -0
- package/esm/storage/prf/index.js.map +1 -0
- package/esm/storage/prf/source.d.ts +27 -0
- package/esm/storage/prf/source.d.ts.map +1 -0
- package/esm/storage/prf/source.js +2 -0
- package/esm/storage/prf/source.js.map +1 -0
- package/esm/storage/prf/storage.d.ts +19 -0
- package/esm/storage/prf/storage.d.ts.map +1 -0
- package/esm/storage/prf/storage.js +125 -0
- package/esm/storage/prf/storage.js.map +1 -0
- package/esm/storage/prf/web-authn.d.ts +55 -0
- package/esm/storage/prf/web-authn.d.ts.map +1 -0
- package/esm/storage/prf/web-authn.js +138 -0
- package/esm/storage/prf/web-authn.js.map +1 -0
- package/esm/storage/type.d.ts +11 -0
- package/esm/storage/type.d.ts.map +1 -0
- package/esm/storage/type.js +2 -0
- package/esm/storage/type.js.map +1 -0
- package/package.json +8 -5
- package/src/index.ts +2 -121
- package/src/manager.ts +82 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/indexed-db/index.ts +2 -0
- package/src/storage/indexed-db/shared.ts +62 -0
- package/src/storage/indexed-db/v1.ts +29 -0
- package/src/storage/indexed-db/v2.ts +95 -0
- package/src/storage/local-storage.ts +37 -0
- package/src/storage/password.ts +245 -0
- package/src/storage/prf/index.ts +3 -0
- package/src/storage/prf/source.ts +35 -0
- package/src/storage/prf/storage.ts +191 -0
- package/src/storage/prf/web-authn.ts +175 -0
- package/src/storage/type.ts +18 -0
- package/CHANGELOG.md +0 -149
- package/tsconfig.build.tsbuildinfo +0 -1
package/src/index.ts
CHANGED
|
@@ -1,121 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import type { AdbCredentialStore, AdbPrivateKey } from "@yume-chan/adb";
|
|
4
|
-
|
|
5
|
-
function openDatabase() {
|
|
6
|
-
return new Promise<IDBDatabase>((resolve, reject) => {
|
|
7
|
-
const request = indexedDB.open("Tango", 1);
|
|
8
|
-
request.onerror = () => {
|
|
9
|
-
reject(request.error!);
|
|
10
|
-
};
|
|
11
|
-
request.onupgradeneeded = () => {
|
|
12
|
-
const db = request.result;
|
|
13
|
-
db.createObjectStore("Authentication", { autoIncrement: true });
|
|
14
|
-
};
|
|
15
|
-
request.onsuccess = () => {
|
|
16
|
-
const db = request.result;
|
|
17
|
-
resolve(db);
|
|
18
|
-
};
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
async function saveKey(key: Uint8Array): Promise<void> {
|
|
23
|
-
const db = await openDatabase();
|
|
24
|
-
|
|
25
|
-
return new Promise((resolve, reject) => {
|
|
26
|
-
const transaction = db.transaction("Authentication", "readwrite");
|
|
27
|
-
const store = transaction.objectStore("Authentication");
|
|
28
|
-
const putRequest = store.add(key);
|
|
29
|
-
putRequest.onerror = () => {
|
|
30
|
-
reject(putRequest.error!);
|
|
31
|
-
};
|
|
32
|
-
putRequest.onsuccess = () => {
|
|
33
|
-
resolve();
|
|
34
|
-
};
|
|
35
|
-
transaction.onerror = () => {
|
|
36
|
-
reject(transaction.error!);
|
|
37
|
-
};
|
|
38
|
-
transaction.oncomplete = () => {
|
|
39
|
-
db.close();
|
|
40
|
-
};
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function getAllKeys() {
|
|
45
|
-
const db = await openDatabase();
|
|
46
|
-
|
|
47
|
-
return new Promise<Uint8Array[]>((resolve, reject) => {
|
|
48
|
-
const transaction = db.transaction("Authentication", "readonly");
|
|
49
|
-
const store = transaction.objectStore("Authentication");
|
|
50
|
-
const getRequest = store.getAll();
|
|
51
|
-
getRequest.onerror = () => {
|
|
52
|
-
reject(getRequest.error!);
|
|
53
|
-
};
|
|
54
|
-
getRequest.onsuccess = () => {
|
|
55
|
-
resolve(getRequest.result as Uint8Array[]);
|
|
56
|
-
};
|
|
57
|
-
transaction.onerror = () => {
|
|
58
|
-
reject(transaction.error!);
|
|
59
|
-
};
|
|
60
|
-
transaction.oncomplete = () => {
|
|
61
|
-
db.close();
|
|
62
|
-
};
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* An `AdbCredentialStore` implementation that creates RSA private keys using Web Crypto API
|
|
68
|
-
* and stores them in IndexedDB.
|
|
69
|
-
*/
|
|
70
|
-
export default class AdbWebCredentialStore implements AdbCredentialStore {
|
|
71
|
-
readonly #appName: string;
|
|
72
|
-
|
|
73
|
-
constructor(appName = "Tango") {
|
|
74
|
-
this.#appName = appName;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Generates a RSA private key and store it into LocalStorage.
|
|
79
|
-
*
|
|
80
|
-
* Calling this method multiple times will overwrite the previous key.
|
|
81
|
-
*
|
|
82
|
-
* @returns The private key in PKCS #8 format.
|
|
83
|
-
*/
|
|
84
|
-
async generateKey(): Promise<AdbPrivateKey> {
|
|
85
|
-
const { privateKey: cryptoKey } = await crypto.subtle.generateKey(
|
|
86
|
-
{
|
|
87
|
-
name: "RSASSA-PKCS1-v1_5",
|
|
88
|
-
modulusLength: 2048,
|
|
89
|
-
// 65537
|
|
90
|
-
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
|
91
|
-
hash: "SHA-1",
|
|
92
|
-
},
|
|
93
|
-
true,
|
|
94
|
-
["sign", "verify"],
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
const privateKey = new Uint8Array(
|
|
98
|
-
await crypto.subtle.exportKey("pkcs8", cryptoKey),
|
|
99
|
-
);
|
|
100
|
-
await saveKey(privateKey);
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
buffer: privateKey,
|
|
104
|
-
name: `${this.#appName}@${globalThis.location.hostname}`,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Yields the stored RSA private key.
|
|
110
|
-
*
|
|
111
|
-
* This method returns a generator, so `for await...of...` loop should be used to read the key.
|
|
112
|
-
*/
|
|
113
|
-
async *iterateKeys(): AsyncGenerator<AdbPrivateKey, void, void> {
|
|
114
|
-
for (const key of await getAllKeys()) {
|
|
115
|
-
yield {
|
|
116
|
-
buffer: key,
|
|
117
|
-
name: `${this.#appName}@${globalThis.location.hostname}`,
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
1
|
+
export * from "./manager.js";
|
|
2
|
+
export * from "./storage/index.js";
|
package/src/manager.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdbCredentialManager,
|
|
3
|
+
AdbPrivateKey,
|
|
4
|
+
MaybeError,
|
|
5
|
+
} from "@yume-chan/adb";
|
|
6
|
+
import { rsaParsePrivateKey } from "@yume-chan/adb";
|
|
7
|
+
|
|
8
|
+
import type { TangoKeyStorage } from "./storage/index.js";
|
|
9
|
+
|
|
10
|
+
export class AdbWebCryptoCredentialManager implements AdbCredentialManager {
|
|
11
|
+
readonly #storage: TangoKeyStorage;
|
|
12
|
+
|
|
13
|
+
readonly #name: string | undefined;
|
|
14
|
+
|
|
15
|
+
constructor(storage: TangoKeyStorage, name?: string) {
|
|
16
|
+
this.#storage = storage;
|
|
17
|
+
this.#name = name;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async generateKey(): Promise<AdbPrivateKey> {
|
|
21
|
+
// NOTE: ADB public key authentication doesn't use standard
|
|
22
|
+
// RSASSA-PKCS1-v1_5 algorithm to sign and verify data.
|
|
23
|
+
// We implemented ADB public key authentication ourselves in core package,
|
|
24
|
+
// so some parameters for Web Crypto API are not used.
|
|
25
|
+
|
|
26
|
+
const { privateKey: cryptoKey } = await crypto.subtle.generateKey(
|
|
27
|
+
{
|
|
28
|
+
name: "RSASSA-PKCS1-v1_5",
|
|
29
|
+
modulusLength: 2048,
|
|
30
|
+
// 65537
|
|
31
|
+
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
|
32
|
+
// Not used
|
|
33
|
+
hash: "SHA-1",
|
|
34
|
+
},
|
|
35
|
+
true,
|
|
36
|
+
// Not used
|
|
37
|
+
["sign"],
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const privateKey = new Uint8Array(
|
|
41
|
+
await crypto.subtle.exportKey("pkcs8", cryptoKey),
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const parsed = rsaParsePrivateKey(privateKey);
|
|
45
|
+
|
|
46
|
+
await this.#storage.save(privateKey, this.#name);
|
|
47
|
+
|
|
48
|
+
// Clear secret memory
|
|
49
|
+
// * `privateKey` is not allowed to be used after `save`
|
|
50
|
+
privateKey.fill(0);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
...parsed,
|
|
54
|
+
name: this.#name,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async *iterateKeys(): AsyncGenerator<
|
|
59
|
+
MaybeError<AdbPrivateKey>,
|
|
60
|
+
void,
|
|
61
|
+
void
|
|
62
|
+
> {
|
|
63
|
+
for await (const result of this.#storage.load()) {
|
|
64
|
+
if (result instanceof Error) {
|
|
65
|
+
yield result;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// `privateKey` is owned by `#storage` and will be cleared by it
|
|
71
|
+
yield {
|
|
72
|
+
...rsaParsePrivateKey(result.privateKey),
|
|
73
|
+
name: result.name ?? this.#name,
|
|
74
|
+
};
|
|
75
|
+
} catch (e) {
|
|
76
|
+
yield e instanceof Error
|
|
77
|
+
? e
|
|
78
|
+
: new Error(String(e), { cause: e });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
export function waitRequest<T>(request: IDBRequest<T>): Promise<T> {
|
|
2
|
+
return new Promise<T>((resolve, reject) => {
|
|
3
|
+
request.onerror = () => {
|
|
4
|
+
reject(request.error!);
|
|
5
|
+
};
|
|
6
|
+
request.onsuccess = () => {
|
|
7
|
+
resolve(request.result);
|
|
8
|
+
};
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function openDatabase(
|
|
13
|
+
name: string,
|
|
14
|
+
version: number,
|
|
15
|
+
onUpgrade: (db: IDBDatabase) => undefined,
|
|
16
|
+
): Promise<IDBDatabase> {
|
|
17
|
+
const request = indexedDB.open(name, version);
|
|
18
|
+
|
|
19
|
+
request.onupgradeneeded = () => {
|
|
20
|
+
const db = request.result;
|
|
21
|
+
onUpgrade(db);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
return await waitRequest(request);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createTransaction<T>(
|
|
28
|
+
database: IDBDatabase,
|
|
29
|
+
storeName: string,
|
|
30
|
+
callback: (transaction: IDBTransaction) => T,
|
|
31
|
+
): Promise<T> {
|
|
32
|
+
return new Promise<T>((resolve, reject) => {
|
|
33
|
+
const transaction = database.transaction(storeName, "readwrite");
|
|
34
|
+
transaction.onerror = () => {
|
|
35
|
+
reject(transaction.error!);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let result!: T;
|
|
39
|
+
transaction.oncomplete = () => {
|
|
40
|
+
resolve(result);
|
|
41
|
+
};
|
|
42
|
+
transaction.onabort = () => {
|
|
43
|
+
reject(transaction.error ?? new Error("Transaction aborted"));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
result = callback(transaction);
|
|
48
|
+
if (result instanceof Promise) {
|
|
49
|
+
throw new Error("callback must not be an async function");
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
|
53
|
+
reject(e);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
transaction.abort();
|
|
57
|
+
} catch {
|
|
58
|
+
// ignore
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createTransaction, openDatabase, waitRequest } from "./shared.js";
|
|
2
|
+
|
|
3
|
+
export const DefaultDatabaseName = "Tango";
|
|
4
|
+
export const DefaultStoreName = "Authentication";
|
|
5
|
+
export const Version1 = 1;
|
|
6
|
+
|
|
7
|
+
export async function getAllKeysV1() {
|
|
8
|
+
const databases = await indexedDB.databases();
|
|
9
|
+
if (
|
|
10
|
+
databases.every(
|
|
11
|
+
(database) =>
|
|
12
|
+
database.name !== DefaultDatabaseName ||
|
|
13
|
+
database.version !== Version1,
|
|
14
|
+
)
|
|
15
|
+
) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const db = await openDatabase(DefaultDatabaseName, Version1, () => {});
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
return await createTransaction(db, DefaultStoreName, (tx) => {
|
|
23
|
+
const store = tx.objectStore(DefaultStoreName);
|
|
24
|
+
return waitRequest(store.getAll() as IDBRequest<Uint8Array[]>);
|
|
25
|
+
});
|
|
26
|
+
} finally {
|
|
27
|
+
db.close();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { TangoKey, TangoKeyStorage } from "../type.js";
|
|
2
|
+
|
|
3
|
+
import { createTransaction, openDatabase, waitRequest } from "./shared.js";
|
|
4
|
+
import { DefaultDatabaseName, DefaultStoreName, getAllKeysV1 } from "./v1.js";
|
|
5
|
+
|
|
6
|
+
const Version = 2;
|
|
7
|
+
|
|
8
|
+
export class TangoIndexedDbStorage implements TangoKeyStorage {
|
|
9
|
+
readonly #databaseName: string;
|
|
10
|
+
get databaseName() {
|
|
11
|
+
return this.#databaseName;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
readonly #storeName: string;
|
|
15
|
+
get storeName() {
|
|
16
|
+
return this.#storeName;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#openDatabasePromise: Promise<IDBDatabase> | undefined;
|
|
20
|
+
|
|
21
|
+
constructor(options?: { databaseName?: string; storeName?: string }) {
|
|
22
|
+
this.#databaseName = options?.databaseName ?? DefaultDatabaseName;
|
|
23
|
+
this.#storeName = options?.storeName ?? DefaultStoreName;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async #openDatabaseCore() {
|
|
27
|
+
const v1Keys = await getAllKeysV1();
|
|
28
|
+
if (v1Keys) {
|
|
29
|
+
await waitRequest(indexedDB.deleteDatabase(DefaultDatabaseName));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return await openDatabase(this.#databaseName, Version, (db) => {
|
|
33
|
+
const store = db.createObjectStore(this.#storeName, {
|
|
34
|
+
autoIncrement: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (v1Keys) {
|
|
38
|
+
for (const key of v1Keys) {
|
|
39
|
+
store.add({
|
|
40
|
+
privateKey: key,
|
|
41
|
+
name: undefined,
|
|
42
|
+
} satisfies TangoKey);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async #openDatabase() {
|
|
49
|
+
return (this.#openDatabasePromise ??= this.#openDatabaseCore());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async save(
|
|
53
|
+
privateKey: Uint8Array,
|
|
54
|
+
name: string | undefined,
|
|
55
|
+
): Promise<undefined> {
|
|
56
|
+
const db = await this.#openDatabase();
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await createTransaction(db, this.#storeName, (tx) => {
|
|
60
|
+
const store = tx.objectStore(this.#storeName);
|
|
61
|
+
store.add({ privateKey, name } satisfies TangoKey);
|
|
62
|
+
});
|
|
63
|
+
} finally {
|
|
64
|
+
db.close();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async *load(): AsyncGenerator<TangoKey, void, void> {
|
|
69
|
+
const db = await this.#openDatabase();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const keys = await createTransaction(db, this.#storeName, (tx) => {
|
|
73
|
+
const store = tx.objectStore(this.#storeName);
|
|
74
|
+
return waitRequest(store.getAll() as IDBRequest<TangoKey[]>);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
yield* keys;
|
|
78
|
+
} finally {
|
|
79
|
+
db.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async clear() {
|
|
84
|
+
const db = await this.#openDatabase();
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await createTransaction(db, this.#storeName, (tx) => {
|
|
88
|
+
const store = tx.objectStore(this.#storeName);
|
|
89
|
+
store.clear();
|
|
90
|
+
});
|
|
91
|
+
} finally {
|
|
92
|
+
db.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { decodeBase64, decodeUtf8, encodeBase64 } from "@yume-chan/adb";
|
|
2
|
+
|
|
3
|
+
import type { TangoKey, TangoKeyStorage } from "./type.js";
|
|
4
|
+
|
|
5
|
+
type TangoKeyJson = {
|
|
6
|
+
[K in keyof TangoKey]: TangoKey[K] extends Uint8Array
|
|
7
|
+
? string
|
|
8
|
+
: TangoKey[K];
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export class TangoLocalStorage implements TangoKeyStorage {
|
|
12
|
+
readonly #storageKey: string;
|
|
13
|
+
|
|
14
|
+
constructor(storageKey: string) {
|
|
15
|
+
this.#storageKey = storageKey;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
save(privateKey: Uint8Array, name: string | undefined): undefined {
|
|
19
|
+
const json = JSON.stringify({
|
|
20
|
+
privateKey: decodeUtf8(encodeBase64(privateKey)),
|
|
21
|
+
name,
|
|
22
|
+
} satisfies TangoKeyJson);
|
|
23
|
+
|
|
24
|
+
localStorage.setItem(this.#storageKey, json);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
*load(): Generator<TangoKey, void, void> {
|
|
28
|
+
const json = localStorage.getItem(this.#storageKey);
|
|
29
|
+
if (json) {
|
|
30
|
+
const { privateKey, name } = JSON.parse(json) as TangoKeyJson;
|
|
31
|
+
yield {
|
|
32
|
+
privateKey: decodeBase64(privateKey),
|
|
33
|
+
name,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { MaybeError } from "@yume-chan/adb";
|
|
2
|
+
import { encodeUtf8, toLocalUint8Array } from "@yume-chan/adb";
|
|
3
|
+
import type { MaybePromiseLike } from "@yume-chan/async";
|
|
4
|
+
import {
|
|
5
|
+
buffer,
|
|
6
|
+
struct,
|
|
7
|
+
u16,
|
|
8
|
+
u32,
|
|
9
|
+
u8,
|
|
10
|
+
Uint8ArrayExactReadable,
|
|
11
|
+
} from "@yume-chan/struct";
|
|
12
|
+
|
|
13
|
+
import type { TangoKey, TangoKeyStorage } from "./type.js";
|
|
14
|
+
|
|
15
|
+
const DefaultPbkdf2SaltLength = 16; // Recommended
|
|
16
|
+
const MinimalPbkdf2SaltLength = 8; // Not recommended but still could be considered secure
|
|
17
|
+
const MaximalPbkdf2SaltLength = 255; // Max length can be stored in `Bundle`
|
|
18
|
+
|
|
19
|
+
const DefaultPbkdf2Iterations = 1_000_000; // Very secure according to OWASP Cheat Sheet
|
|
20
|
+
const MinimalPbkdf2Iterations = 10_000; // Not recommended but still could be considered secure
|
|
21
|
+
const MaximalPbkdf2Iterations = 4294967295; // 2^32 - 1, max value can be stored in `Bundle`
|
|
22
|
+
|
|
23
|
+
const DefaultAesIvLength = 12; // 12 bytes (96 bits) is the native IV length
|
|
24
|
+
const MinimalAesIvLength = 1; // Any value except 12 is not recommended, but could work
|
|
25
|
+
const MaximalAesIvLength = 255; // Max length can be stored in `Bundle`
|
|
26
|
+
|
|
27
|
+
const Bundle = struct(
|
|
28
|
+
{
|
|
29
|
+
pbkdf2Salt: buffer(u8),
|
|
30
|
+
pbkdf2Iterations: u32,
|
|
31
|
+
aesIv: buffer(u8),
|
|
32
|
+
encrypted: buffer(u16),
|
|
33
|
+
},
|
|
34
|
+
{ littleEndian: true },
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
async function deriveAesKey(
|
|
38
|
+
password: string,
|
|
39
|
+
saltOrLength: Uint8Array<ArrayBuffer> | number,
|
|
40
|
+
iterations: number,
|
|
41
|
+
) {
|
|
42
|
+
const baseKey = await crypto.subtle.importKey(
|
|
43
|
+
"raw",
|
|
44
|
+
encodeUtf8(password),
|
|
45
|
+
"PBKDF2",
|
|
46
|
+
false,
|
|
47
|
+
["deriveKey"],
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
let salt: Uint8Array<ArrayBuffer>;
|
|
51
|
+
if (typeof saltOrLength === "number") {
|
|
52
|
+
salt = new Uint8Array(saltOrLength);
|
|
53
|
+
crypto.getRandomValues(salt);
|
|
54
|
+
} else {
|
|
55
|
+
salt = saltOrLength;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const aesKey = await crypto.subtle.deriveKey(
|
|
59
|
+
{
|
|
60
|
+
name: "PBKDF2",
|
|
61
|
+
salt,
|
|
62
|
+
iterations,
|
|
63
|
+
hash: "SHA-256",
|
|
64
|
+
},
|
|
65
|
+
baseKey,
|
|
66
|
+
{ name: "AES-GCM", length: 256 },
|
|
67
|
+
false,
|
|
68
|
+
["encrypt", "decrypt"],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return { salt, aesKey };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class PasswordIncorrectError extends Error {
|
|
75
|
+
#keyName: string | undefined;
|
|
76
|
+
get keyName() {
|
|
77
|
+
return this.#keyName;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
constructor(keyName: string | undefined) {
|
|
81
|
+
super("Password incorrect");
|
|
82
|
+
this.#keyName = keyName;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// eslint-disable-next-line @typescript-eslint/max-params
|
|
87
|
+
function checkIntegerRange(
|
|
88
|
+
name: string,
|
|
89
|
+
value: number | undefined,
|
|
90
|
+
defaultValue: number,
|
|
91
|
+
min: number,
|
|
92
|
+
max: number,
|
|
93
|
+
): number {
|
|
94
|
+
if (value === undefined) {
|
|
95
|
+
return defaultValue;
|
|
96
|
+
}
|
|
97
|
+
if (!Number.isInteger(value) || value < min || value > max) {
|
|
98
|
+
throw new Error(`${name} must be an integer between ${min} and ${max}`);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class TangoPasswordProtectedStorage implements TangoKeyStorage {
|
|
104
|
+
static PasswordIncorrectError = PasswordIncorrectError;
|
|
105
|
+
|
|
106
|
+
readonly #storage: TangoKeyStorage;
|
|
107
|
+
readonly #requestPassword: TangoPasswordProtectedStorage.RequestPassword;
|
|
108
|
+
readonly #pbkdf2SaltLength: number;
|
|
109
|
+
readonly #pbkdf2Iterations: number;
|
|
110
|
+
readonly #aesIvLength: number;
|
|
111
|
+
|
|
112
|
+
constructor(options: {
|
|
113
|
+
storage: TangoKeyStorage;
|
|
114
|
+
requestPassword: TangoPasswordProtectedStorage.RequestPassword;
|
|
115
|
+
pbkdf2SaltLength?: number | undefined;
|
|
116
|
+
pbkdf2Iterations?: number | undefined;
|
|
117
|
+
aesIvLength?: number | undefined;
|
|
118
|
+
}) {
|
|
119
|
+
this.#storage = options.storage;
|
|
120
|
+
this.#requestPassword = options.requestPassword;
|
|
121
|
+
|
|
122
|
+
this.#pbkdf2SaltLength = checkIntegerRange(
|
|
123
|
+
"pbkdf2SaltLength",
|
|
124
|
+
options.pbkdf2SaltLength,
|
|
125
|
+
DefaultPbkdf2SaltLength,
|
|
126
|
+
MinimalPbkdf2SaltLength,
|
|
127
|
+
MaximalPbkdf2SaltLength,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
this.#pbkdf2Iterations = checkIntegerRange(
|
|
131
|
+
"pbkdf2Iterations",
|
|
132
|
+
options.pbkdf2Iterations,
|
|
133
|
+
DefaultPbkdf2Iterations,
|
|
134
|
+
MinimalPbkdf2Iterations,
|
|
135
|
+
MaximalPbkdf2Iterations,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
this.#aesIvLength = checkIntegerRange(
|
|
139
|
+
"aesIvLength",
|
|
140
|
+
options.aesIvLength,
|
|
141
|
+
DefaultAesIvLength,
|
|
142
|
+
MinimalAesIvLength,
|
|
143
|
+
MaximalAesIvLength,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async save(
|
|
148
|
+
privateKey: Uint8Array,
|
|
149
|
+
name: string | undefined,
|
|
150
|
+
): Promise<undefined> {
|
|
151
|
+
const password = await this.#requestPassword("save", name);
|
|
152
|
+
const { salt, aesKey } = await deriveAesKey(
|
|
153
|
+
password,
|
|
154
|
+
this.#pbkdf2SaltLength,
|
|
155
|
+
this.#pbkdf2Iterations,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const iv = new Uint8Array(this.#aesIvLength);
|
|
159
|
+
crypto.getRandomValues(iv);
|
|
160
|
+
|
|
161
|
+
const encrypted = await crypto.subtle.encrypt(
|
|
162
|
+
{ name: "AES-GCM", iv },
|
|
163
|
+
aesKey,
|
|
164
|
+
toLocalUint8Array(privateKey),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const bundle = Bundle.serialize({
|
|
168
|
+
pbkdf2Salt: salt,
|
|
169
|
+
pbkdf2Iterations: this.#pbkdf2Iterations,
|
|
170
|
+
aesIv: iv,
|
|
171
|
+
encrypted: new Uint8Array(encrypted),
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
await this.#storage.save(bundle, name);
|
|
175
|
+
|
|
176
|
+
// Clear secret memory
|
|
177
|
+
// * No way to clear `password` and `aesKey`
|
|
178
|
+
// * `salt`, `iv`, `encrypted` and `bundle` are not secrets
|
|
179
|
+
// * `privateKey` is owned by caller and will be cleared by caller
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async *load(): AsyncGenerator<MaybeError<TangoKey>, void, void> {
|
|
183
|
+
for await (const result of this.#storage.load()) {
|
|
184
|
+
if (result instanceof Error) {
|
|
185
|
+
yield result;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { privateKey: serialized, name } = result;
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
const bundle = Bundle.deserialize(
|
|
193
|
+
new Uint8ArrayExactReadable(serialized),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const password = await this.#requestPassword("load", name);
|
|
197
|
+
const { aesKey } = await deriveAesKey(
|
|
198
|
+
password,
|
|
199
|
+
bundle.pbkdf2Salt as Uint8Array<ArrayBuffer>,
|
|
200
|
+
bundle.pbkdf2Iterations,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
const decrypted = await crypto.subtle.decrypt(
|
|
204
|
+
{
|
|
205
|
+
name: "AES-GCM",
|
|
206
|
+
iv: bundle.aesIv as Uint8Array<ArrayBuffer>,
|
|
207
|
+
},
|
|
208
|
+
aesKey,
|
|
209
|
+
bundle.encrypted as Uint8Array<ArrayBuffer>,
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
yield {
|
|
214
|
+
privateKey: new Uint8Array(decrypted),
|
|
215
|
+
name,
|
|
216
|
+
};
|
|
217
|
+
} finally {
|
|
218
|
+
// Clear secret memory
|
|
219
|
+
// * No way to clear `password` and `aesKey`
|
|
220
|
+
// * all values in `bundle` are not secrets
|
|
221
|
+
// * Caller is not allowed to use `decrypted` after `yield` returns
|
|
222
|
+
new Uint8Array(decrypted).fill(0);
|
|
223
|
+
}
|
|
224
|
+
} catch (e) {
|
|
225
|
+
if (e instanceof DOMException && e.name === "OperationError") {
|
|
226
|
+
yield new PasswordIncorrectError(name);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
yield e instanceof Error
|
|
231
|
+
? e
|
|
232
|
+
: new Error(String(e), { cause: e });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export namespace TangoPasswordProtectedStorage {
|
|
239
|
+
export type RequestPassword = (
|
|
240
|
+
reason: "save" | "load",
|
|
241
|
+
name: string | undefined,
|
|
242
|
+
) => MaybePromiseLike<string>;
|
|
243
|
+
|
|
244
|
+
export type PasswordIncorrectError = typeof PasswordIncorrectError;
|
|
245
|
+
}
|