@yume-chan/adb-credential-web 2.0.1 → 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.
Files changed (75) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +6 -3
  3. package/esm/index.d.ts +2 -23
  4. package/esm/index.d.ts.map +1 -1
  5. package/esm/index.js +2 -102
  6. package/esm/index.js.map +1 -1
  7. package/esm/manager.d.ts +9 -0
  8. package/esm/manager.d.ts.map +1 -0
  9. package/esm/manager.js +56 -0
  10. package/esm/manager.js.map +1 -0
  11. package/esm/storage/index.d.ts +6 -0
  12. package/esm/storage/index.d.ts.map +1 -0
  13. package/esm/storage/index.js +6 -0
  14. package/esm/storage/index.js.map +1 -0
  15. package/esm/storage/indexed-db/index.d.ts +3 -0
  16. package/esm/storage/indexed-db/index.d.ts.map +1 -0
  17. package/esm/storage/indexed-db/index.js +3 -0
  18. package/esm/storage/indexed-db/index.js.map +1 -0
  19. package/esm/storage/indexed-db/shared.d.ts +4 -0
  20. package/esm/storage/indexed-db/shared.d.ts.map +1 -0
  21. package/esm/storage/indexed-db/shared.js +50 -0
  22. package/esm/storage/indexed-db/shared.js.map +1 -0
  23. package/esm/storage/indexed-db/v1.d.ts +5 -0
  24. package/esm/storage/indexed-db/v1.d.ts.map +1 -0
  25. package/esm/storage/indexed-db/v1.js +22 -0
  26. package/esm/storage/indexed-db/v1.js.map +1 -0
  27. package/esm/storage/indexed-db/v2.d.ts +14 -0
  28. package/esm/storage/indexed-db/v2.d.ts.map +1 -0
  29. package/esm/storage/indexed-db/v2.js +78 -0
  30. package/esm/storage/indexed-db/v2.js.map +1 -0
  31. package/esm/storage/local-storage.d.ts +8 -0
  32. package/esm/storage/local-storage.d.ts.map +1 -0
  33. package/esm/storage/local-storage.js +25 -0
  34. package/esm/storage/local-storage.js.map +1 -0
  35. package/esm/storage/password.d.ts +27 -0
  36. package/esm/storage/password.d.ts.map +1 -0
  37. package/esm/storage/password.js +129 -0
  38. package/esm/storage/password.js.map +1 -0
  39. package/esm/storage/prf/index.d.ts +4 -0
  40. package/esm/storage/prf/index.d.ts.map +1 -0
  41. package/esm/storage/prf/index.js +4 -0
  42. package/esm/storage/prf/index.js.map +1 -0
  43. package/esm/storage/prf/source.d.ts +27 -0
  44. package/esm/storage/prf/source.d.ts.map +1 -0
  45. package/esm/storage/prf/source.js +2 -0
  46. package/esm/storage/prf/source.js.map +1 -0
  47. package/esm/storage/prf/storage.d.ts +19 -0
  48. package/esm/storage/prf/storage.d.ts.map +1 -0
  49. package/esm/storage/prf/storage.js +125 -0
  50. package/esm/storage/prf/storage.js.map +1 -0
  51. package/esm/storage/prf/web-authn.d.ts +55 -0
  52. package/esm/storage/prf/web-authn.d.ts.map +1 -0
  53. package/esm/storage/prf/web-authn.js +138 -0
  54. package/esm/storage/prf/web-authn.js.map +1 -0
  55. package/esm/storage/type.d.ts +11 -0
  56. package/esm/storage/type.d.ts.map +1 -0
  57. package/esm/storage/type.js +2 -0
  58. package/esm/storage/type.js.map +1 -0
  59. package/package.json +8 -5
  60. package/src/index.ts +2 -121
  61. package/src/manager.ts +82 -0
  62. package/src/storage/index.ts +5 -0
  63. package/src/storage/indexed-db/index.ts +2 -0
  64. package/src/storage/indexed-db/shared.ts +62 -0
  65. package/src/storage/indexed-db/v1.ts +29 -0
  66. package/src/storage/indexed-db/v2.ts +95 -0
  67. package/src/storage/local-storage.ts +37 -0
  68. package/src/storage/password.ts +245 -0
  69. package/src/storage/prf/index.ts +3 -0
  70. package/src/storage/prf/source.ts +35 -0
  71. package/src/storage/prf/storage.ts +191 -0
  72. package/src/storage/prf/web-authn.ts +175 -0
  73. package/src/storage/type.ts +18 -0
  74. package/CHANGELOG.md +0 -141
  75. package/tsconfig.build.tsbuildinfo +0 -1
package/src/index.ts CHANGED
@@ -1,121 +1,2 @@
1
- // cspell: ignore RSASSA
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,5 @@
1
+ export * from "./indexed-db/index.js";
2
+ export * from "./local-storage.js";
3
+ export * from "./password.js";
4
+ export * from "./prf/index.js";
5
+ export * from "./type.js";
@@ -0,0 +1,2 @@
1
+ export * as IndexedDb from "./shared.js";
2
+ export * from "./v2.js";
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./source.js";
2
+ export * from "./storage.js";
3
+ export * from "./web-authn.js";