@shuttle.technology/authorization 0.1.0 → 0.2.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 (52) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +1082 -666
  3. package/dist/accountLists.d.ts +28 -0
  4. package/dist/accountLists.js +27 -0
  5. package/dist/auth.d.ts +11 -11
  6. package/dist/auth.js +28 -54
  7. package/dist/client.d.ts +62 -174
  8. package/dist/client.js +381 -354
  9. package/dist/clientTypes.d.ts +56 -0
  10. package/dist/clientTypes.js +5 -0
  11. package/dist/configuration.d.ts +48 -0
  12. package/dist/configuration.js +72 -0
  13. package/dist/consentDebit.d.ts +36 -0
  14. package/dist/consentDebit.js +95 -0
  15. package/dist/credentials.d.ts +116 -0
  16. package/dist/credentials.js +315 -0
  17. package/dist/debitPermissionWire.d.ts +90 -0
  18. package/dist/debitPermissionWire.js +118 -0
  19. package/dist/debitPermissions.d.ts +39 -0
  20. package/dist/debitPermissions.js +117 -0
  21. package/dist/deviceRecovery.d.ts +40 -0
  22. package/dist/deviceRecovery.js +143 -0
  23. package/dist/deviceShares.d.ts +91 -0
  24. package/dist/deviceShares.js +204 -0
  25. package/dist/errors.d.ts +280 -0
  26. package/dist/errors.js +85 -0
  27. package/dist/linkedRecovery.d.ts +40 -0
  28. package/dist/linkedRecovery.js +49 -0
  29. package/dist/linkedRegistrations.d.ts +11 -0
  30. package/dist/linkedRegistrations.js +109 -0
  31. package/dist/linkedWallets.d.ts +46 -18
  32. package/dist/linkedWallets.js +85 -120
  33. package/dist/me.d.ts +27 -0
  34. package/dist/me.js +30 -0
  35. package/dist/mobile.d.ts +64 -0
  36. package/dist/mobile.js +69 -0
  37. package/dist/redirects.d.ts +45 -0
  38. package/dist/redirects.js +108 -0
  39. package/dist/serviceGrants.d.ts +52 -0
  40. package/dist/serviceGrants.js +46 -0
  41. package/dist/slice4.d.ts +20 -6
  42. package/dist/slice4.js +22 -8
  43. package/dist/streams.js +1 -1
  44. package/dist/walletCreateGrant.d.ts +62 -0
  45. package/dist/walletCreateGrant.js +89 -0
  46. package/dist/walletDisplayParts.d.ts +2 -0
  47. package/dist/walletDisplayParts.js +4 -0
  48. package/dist/walletOperations.d.ts +23 -6
  49. package/dist/walletOperations.js +31 -22
  50. package/dist/webRenewal.d.ts +92 -0
  51. package/dist/webRenewal.js +179 -0
  52. package/package.json +25 -23
@@ -0,0 +1,117 @@
1
+ // Card-like debits on the person's side: the permissions an account lets a
2
+ // service debit its linked wallets by, without the device. Reach it with
3
+ // client.linkedWallets().debitPermissions(). A permission and its revocation
4
+ // are local operations the person confirms with a passkey through the
5
+ // client's own challengeOperation() and completeOperationConfirmation().
6
+ // executeDebitPermission() then applies a new permission, handing the
7
+ // wallet's device share to the platform share keeper sealed to the key the
8
+ // keeper made for that operation: the share the SDK keeps (deviceShares.ts),
9
+ // or the one an app that keeps its own passes; a revocation is applied with the client's
10
+ // executeOperation(), without a body. The debits themselves are made by the
11
+ // service's backend with its own key (POST /v1/service/operations).
12
+ import { ClientFlowError, canonicalDecimal, record } from './auth.js';
13
+ import { resealDeviceShare } from './deviceShare.js';
14
+ import { debitPermissionCreate, debitPermissionRevocation, parseDebitPermissionList } from './debitPermissionWire.js';
15
+ import { fault, hex, requestID } from './linkedChecks.js';
16
+ import { parseOperationEnvelope } from './slice4.js';
17
+ export { MaxDebitAssets, MaxDebitRecipients, debitPermissionCreate, debitPermissionRevocation, parseDebitPermissionDisplay, parseDebitPermissionList, parseDebitPermissionRevokeDisplay } from './debitPermissionWire.js';
18
+ export class DebitPermissions {
19
+ #transport;
20
+ #kept;
21
+ constructor(transport, kept) { this.#transport = transport; this.#kept = kept; }
22
+ /**
23
+ * Asks to let one service debit one linked wallet within the bounds given. The operation comes back
24
+ * awaiting the person's passkey; its display says the bounds as the provider keeps them and, when the
25
+ * keeper does not hold the wallet's device share yet, the key the execute seals it to.
26
+ */
27
+ async createDebitPermission(input, idempotencyKey, signal) {
28
+ const create = debitPermissionCreate(this.#transport.accountId(), input);
29
+ requestID(idempotencyKey);
30
+ const { payload, accountId } = await this.#transport.write('/v1/operations', 'POST', create, signal, idempotencyKey);
31
+ const operation = parseOperationEnvelope(payload).operation;
32
+ if (operation.display.kind !== 'debit_permission.create' || operation.ownerId !== accountId || operation.display.linkedWalletId !== create.linked_wallet_id ||
33
+ operation.display.servicePrincipalId !== input.servicePrincipalId)
34
+ fault();
35
+ return operation;
36
+ }
37
+ /**
38
+ * Applies a confirmed permission. It reads the operation first: when it is confirmed (authorized, at the
39
+ * version given) and its display names the share keeper's key, the device share is resealed to that key and sent: without keys the
40
+ * share the SDK keeps for the wallet (recovered first with one passkey confirmation when this browser holds none), with keys the
41
+ * app's own (resealDeviceShare). The plaintext share exists only inside that call. Without that key the keeper holds the share already
42
+ * and nothing is sent. The person's own copy stays where it is, and their sends with it go on as before.
43
+ */
44
+ async executeDebitPermission(operationId, version, idempotencyKey, keys, signal) {
45
+ return this.#execute(operationId, version, idempotencyKey, keys, signal, true);
46
+ }
47
+ /**
48
+ * executeConsentDebitPermission is executeDebitPermission() with the share the SDK keeps, for the permission a
49
+ * grant's consent created (consentDebit.ts): it never recovers a share, so reading a result never asks for a
50
+ * passkey; without a current share kept here it refuses with device_share_unavailable and sends nothing.
51
+ */
52
+ async executeConsentDebitPermission(operationId, version, idempotencyKey, signal) {
53
+ return this.#execute(operationId, version, idempotencyKey, undefined, signal, false);
54
+ }
55
+ async #execute(operationId, version, idempotencyKey, keys, signal, recover) {
56
+ requestID(operationId);
57
+ requestID(idempotencyKey);
58
+ if (!canonicalDecimal(version))
59
+ throw new ClientFlowError('invalid_execute_request');
60
+ const read = parseOperationEnvelope(await this.#transport.get(`/v1/operations/${operationId}`, signal)).operation;
61
+ if (read.id !== operationId)
62
+ fault();
63
+ if (read.display.kind !== 'debit_permission.create')
64
+ throw new ClientFlowError('invalid_execute_request');
65
+ let body;
66
+ const keeperKey = read.display.shareKeeperPublicKey;
67
+ // The share leaves the device only for a permission the passkey confirmed and that still waits for this
68
+ // very execute. Any other state (unconfirmed, applied, ended) is executed without it, and the server answers.
69
+ if (keeperKey !== undefined && read.state === 'authorized') {
70
+ if (read.version !== version)
71
+ throw new ClientFlowError('invalid_execute_request');
72
+ const share = keys === undefined ? await this.#keptFor(read, keeperKey, signal, recover) : await this.#sealedFor(keys, keeperKey);
73
+ body = { device_share: { ciphertext: share.ciphertext, nonce: share.nonce, ephemeral_public_key: share.ephemeralPublicKey, encryption_algorithm: share.encryptionAlgorithm, encryption_context: share.encryptionContext } };
74
+ }
75
+ const operation = parseOperationEnvelope((await this.#transport.write(`/v1/operations/${operationId}/execute`, 'POST', body, signal, idempotencyKey, version)).payload).operation;
76
+ if (operation.id !== operationId)
77
+ fault();
78
+ return operation;
79
+ }
80
+ /** The account's permissions in every state, newest first. */
81
+ async listDebitPermissions(signal) {
82
+ return parseDebitPermissionList(await this.#transport.get('/v1/debit-permissions', signal));
83
+ }
84
+ /**
85
+ * Asks to end one permission. The operation awaits the person's passkey; executeOperation() applies it,
86
+ * without a body. A debit not yet executed is refused from then on.
87
+ */
88
+ async revokeDebitPermission(debitPermissionId, idempotencyKey, signal) {
89
+ const create = debitPermissionRevocation(this.#transport.accountId(), debitPermissionId);
90
+ requestID(idempotencyKey);
91
+ const { payload, accountId } = await this.#transport.write('/v1/operations', 'POST', create, signal, idempotencyKey);
92
+ const operation = parseOperationEnvelope(payload).operation;
93
+ if (operation.display.kind !== 'debit_permission.revoke' || operation.ownerId !== accountId || operation.display.debitPermissionId !== debitPermissionId)
94
+ fault();
95
+ return operation;
96
+ }
97
+ async #keptFor(read, keeperKey, signal, recover) {
98
+ if (read.display.kind !== 'debit_permission.create')
99
+ throw new ClientFlowError('invalid_execute_request');
100
+ if (!this.#kept)
101
+ throw new ClientFlowError('device_share_unavailable');
102
+ return this.#kept(read.display.linkedWalletId, keeperKey, signal, recover);
103
+ }
104
+ async #sealedFor(keys, keeperKey) {
105
+ let declared, share;
106
+ try {
107
+ declared = record(keys, ['devicePrivateKey', 'deviceShare']);
108
+ share = record(declared.deviceShare, ['ciphertext', 'nonce', 'ephemeralPublicKey', 'encryptionAlgorithm', 'encryptionContext']);
109
+ }
110
+ catch {
111
+ throw new ClientFlowError('invalid_execute_request');
112
+ }
113
+ if (![share.ciphertext, share.nonce, share.ephemeralPublicKey, share.encryptionContext].every(hex) || typeof share.encryptionAlgorithm !== 'string')
114
+ throw new ClientFlowError('invalid_execute_request');
115
+ return resealDeviceShare(declared.devicePrivateKey, keys.deviceShare, keeperKey);
116
+ }
117
+ }
@@ -0,0 +1,40 @@
1
+ import { type PollOptions } from "./poll.ts";
2
+ import type { DeviceRecoveryOptions, DeviceShareRecord, PendingDeviceShareRecord } from "./deviceShares.ts";
3
+ import type { LinkedWallet } from "./linkedWallets.ts";
4
+ import type { AccountIdentity, ConfirmationChallenge } from "./slice4.ts";
5
+ import type { UserWalletOperation, UserWalletOperationCreate, WalletResult } from "./walletOperations.ts";
6
+ /** The calls of the client the recovery is made of; AuthorizationClient answers them all. */
7
+ export interface RecoveryClient {
8
+ identities(signal?: AbortSignal): Promise<AccountIdentity[]>;
9
+ createWalletOperation(create: UserWalletOperationCreate, idempotencyKey: string, signal?: AbortSignal): Promise<UserWalletOperation>;
10
+ challengeWalletOperation(operationId: string, credentialId: string, signal?: AbortSignal): Promise<ConfirmationChallenge>;
11
+ completeWalletOperationConfirmation(callbackUri: string, signal?: AbortSignal): Promise<UserWalletOperation>;
12
+ executeWalletOperation(operationId: string, version: string, idempotencyKey: string, signal?: AbortSignal): Promise<UserWalletOperation>;
13
+ walletOperation(operationId: string, signal?: AbortSignal): Promise<UserWalletOperation>;
14
+ walletOperationResult(operationId: string, signal?: AbortSignal): Promise<WalletResult>;
15
+ }
16
+ /** How long the SDK reads an executed recovery: rounds of at most a minute each, and the poll's own clock and sleep. */
17
+ export interface RecoverySettling {
18
+ rounds?: number;
19
+ now?: () => number;
20
+ sleep?: PollOptions<UserWalletOperation>["sleep"];
21
+ }
22
+ /** newestPasskey is the account's passkey a recovery is confirmed with when the app names none: the one added last. */
23
+ export declare function newestPasskey(identities: readonly AccountIdentity[]): string;
24
+ /**
25
+ * recoverDeviceShare recovers the device share of one of the account's linked
26
+ * wallets onto a new device key and answers the record to keep. keepPending
27
+ * keeps the confirmed recovery before it is executed. Every refusal of the
28
+ * provider surfaces as its AuthorizationError; what the SDK refuses itself is
29
+ * a ClientFlowError: device_recovery_requires_passkey,
30
+ * hosted_callback_unavailable (the host's launcher did not answer the
31
+ * redirect back), device_recovery_not_confirmed, device_recovery_failed.
32
+ */
33
+ export declare function recoverDeviceShare(client: RecoveryClient, accountId: string, crypto: Crypto, wallet: LinkedWallet, options: DeviceRecoveryOptions, keepPending: (pending: PendingDeviceShareRecord) => Promise<void>, settling?: RecoverySettling): Promise<DeviceShareRecord>;
34
+ /**
35
+ * resumeDeviceRecovery takes up a pending recovery where it stopped: executes
36
+ * it when it was never executed, and reads it to its end. Undefined means it
37
+ * ended without effect (or is gone past its retention), and a new recovery
38
+ * may start; one still under way is device_recovery_failed and stays pending.
39
+ */
40
+ export declare function resumeDeviceRecovery(client: RecoveryClient, crypto: Crypto, wallet: LinkedWallet, pending: PendingDeviceShareRecord, options: DeviceRecoveryOptions, settling?: RecoverySettling): Promise<DeviceShareRecord | undefined>;
@@ -0,0 +1,143 @@
1
+ // The recovery of a linked wallet's device share onto this device (owner
2
+ // decision of 28 October 2026). The wallet's key is three shares, any two of
3
+ // which sign; the own-wallet system rebuilds the device share from the other
4
+ // two and seals it to a new device key made here. The person sees one passkey
5
+ // confirmation on the provider's page, as for creating the wallet: a session
6
+ // alone never yields a device share, since a send needs nothing more than the
7
+ // session and that share. Once it succeeds the share this wallet had on any
8
+ // other device stops working.
9
+ //
10
+ // A confirmed recovery is kept as pending, with its device key, operation and
11
+ // execute key, before it is executed: an answer that is lost or a Wallet that
12
+ // has not said yet leaves it pending, and the next call resumes that same
13
+ // operation instead of starting a second one whose effect a late first one
14
+ // would undo.
15
+ import { AuthorizationError, ClientFlowError, opaqueId } from "./auth.js";
16
+ import { generateDeviceKey } from "./deviceKey.js";
17
+ import { openDeviceShare } from "./deviceShareEnvelope.js";
18
+ import { linkedRecoverCreate } from "./linkedRecovery.js";
19
+ import { pollAuthoritative } from "./poll.js";
20
+ const terminal = new Set(["succeeded", "failed", "denied", "canceled", "expired"]);
21
+ function randomKey(crypto) {
22
+ return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) => byte.toString(16).padStart(2, "0")).join("");
23
+ }
24
+ /** newestPasskey is the account's passkey a recovery is confirmed with when the app names none: the one added last. */
25
+ export function newestPasskey(identities) {
26
+ const passkeys = identities.filter((identity) => identity.method === "passkey" && identity.credentialId !== undefined)
27
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
28
+ if (passkeys.length === 0)
29
+ throw new ClientFlowError("device_recovery_requires_passkey");
30
+ return passkeys[0].credentialId;
31
+ }
32
+ async function settled(client, operation, signal, settling) {
33
+ let last = operation;
34
+ for (let round = 0; round < (settling.rounds ?? 5) && !terminal.has(last.state); round++) {
35
+ const polled = await pollAuthoritative({ read: (stop) => client.walletOperation(operation.id, stop), terminal: (value) => terminal.has(value.state), signal, now: settling.now, sleep: settling.sleep });
36
+ if (polled.lastKnown)
37
+ last = polled.lastKnown;
38
+ if (polled.disposition === "local_canceled")
39
+ throw signal?.reason ?? new ClientFlowError("device_recovery_failed");
40
+ }
41
+ return last;
42
+ }
43
+ /**
44
+ * finish executes a confirmed recovery with its own execute key, unless it was
45
+ * executed already, and reads it to its end. It answers the record to keep,
46
+ * or undefined when the recovery ended without effect; one the Wallet has not
47
+ * finished is device_recovery_failed and stays pending.
48
+ */
49
+ async function finish(client, crypto, wallet, pending, operation, signal, settling) {
50
+ if (operation.state === "authorized")
51
+ operation = await client.executeWalletOperation(pending.pendingOperationId, operation.version, pending.executeKey, signal);
52
+ operation = await settled(client, operation, signal, settling);
53
+ if (operation.state !== "succeeded") {
54
+ if (terminal.has(operation.state))
55
+ return undefined;
56
+ throw new ClientFlowError("device_recovery_failed");
57
+ }
58
+ const { result } = await client.walletOperationResult(operation.id, signal);
59
+ // The recovery names the linked wallet it was admitted for, which a reconnection may since have renamed; the wallet itself is the same.
60
+ if (result.kind !== "device_share_recovered" || result.linkedWalletId !== (pending.pendingLinkedWalletId ?? wallet.id) || result.receiptId !== wallet.externalWalletId || !result.deviceShare || !opaqueId(result.operationId)) {
61
+ throw new ClientFlowError("invalid_server_response");
62
+ }
63
+ // Only a share this device can open is kept.
64
+ (await openDeviceShare(pending.privateKey, result.deviceShare, crypto.subtle)).fill(0);
65
+ return { walletId: wallet.externalWalletId, privateKey: pending.privateKey, publicKeyHex: pending.publicKeyHex, deviceShare: result.deviceShare };
66
+ }
67
+ /**
68
+ * recoverDeviceShare recovers the device share of one of the account's linked
69
+ * wallets onto a new device key and answers the record to keep. keepPending
70
+ * keeps the confirmed recovery before it is executed. Every refusal of the
71
+ * provider surfaces as its AuthorizationError; what the SDK refuses itself is
72
+ * a ClientFlowError: device_recovery_requires_passkey,
73
+ * hosted_callback_unavailable (the host's launcher did not answer the
74
+ * redirect back), device_recovery_not_confirmed, device_recovery_failed.
75
+ */
76
+ export async function recoverDeviceShare(client, accountId, crypto, wallet, options, keepPending, settling = {}) {
77
+ const { signal } = options;
78
+ if (options.credentialId !== undefined && !/^[A-Za-z0-9_-]{1,1024}$/.test(options.credentialId))
79
+ throw new ClientFlowError("invalid_confirmation_request");
80
+ const credentialId = options.credentialId ?? newestPasskey(await client.identities(signal));
81
+ const device = await generateDeviceKey(crypto.subtle);
82
+ let operation = await client.createWalletOperation(linkedRecoverCreate(accountId, wallet.id, device.publicKeyHex), randomKey(crypto), signal);
83
+ if (operation.kind !== "wallet.recover" || operation.linkedWallet?.linkedWalletId !== wallet.id)
84
+ throw new ClientFlowError("invalid_server_response");
85
+ const challenge = await client.challengeWalletOperation(operation.id, credentialId, signal);
86
+ if (typeof challenge.callbackUri !== "string")
87
+ throw new ClientFlowError("hosted_callback_unavailable");
88
+ operation = await client.completeWalletOperationConfirmation(challenge.callbackUri, signal);
89
+ if (operation.state !== "authorized")
90
+ throw new ClientFlowError("device_recovery_not_confirmed");
91
+ const pending = { walletId: wallet.externalWalletId, privateKey: device.privateKey, publicKeyHex: device.publicKeyHex, pendingOperationId: operation.id, executeKey: randomKey(crypto), pendingLinkedWalletId: wallet.id };
92
+ await keepPending(pending);
93
+ const kept = await finish(client, crypto, wallet, pending, operation, signal, settling);
94
+ if (!kept)
95
+ throw new ClientFlowError("device_recovery_failed");
96
+ return kept;
97
+ }
98
+ /**
99
+ * resumeDeviceRecovery takes up a pending recovery where it stopped: executes
100
+ * it when it was never executed, and reads it to its end. Undefined means it
101
+ * ended without effect (or is gone past its retention), and a new recovery
102
+ * may start; one still under way is device_recovery_failed and stays pending.
103
+ */
104
+ export async function resumeDeviceRecovery(client, crypto, wallet, pending, options, settling = {}) {
105
+ let operation;
106
+ try {
107
+ operation = await client.walletOperation(pending.pendingOperationId, options.signal);
108
+ }
109
+ catch (error) {
110
+ if (error instanceof AuthorizationError && error.code === "NOT_FOUND")
111
+ return undefined;
112
+ throw error;
113
+ }
114
+ if (operation.kind !== "wallet.recover" || operation.linkedWallet?.linkedWalletId !== (pending.pendingLinkedWalletId ?? wallet.id))
115
+ throw new ClientFlowError("invalid_server_response");
116
+ // Never confirmed, it can have no effect, and nobody else may confirm it.
117
+ if (operation.state === "awaiting_confirmation")
118
+ return undefined;
119
+ if (operation.state === "authorized") {
120
+ // Never claimed: one past its deadline, or one the provider no longer
121
+ // claims (only the confirming session executes it), can have no effect,
122
+ // and a new recovery replaces it.
123
+ if (Date.parse(operation.expiresAt) <= Date.now())
124
+ return undefined;
125
+ try {
126
+ operation = await client.executeWalletOperation(pending.pendingOperationId, operation.version, pending.executeKey, options.signal);
127
+ }
128
+ catch (error) {
129
+ if (error instanceof AuthorizationError && (error.code === "AUTHORIZATION_STALE" || error.code === "OPERATION_EXPIRED"))
130
+ return undefined;
131
+ throw error;
132
+ }
133
+ }
134
+ try {
135
+ return await finish(client, crypto, wallet, pending, operation, options.signal, settling);
136
+ }
137
+ catch (error) {
138
+ // A result past its retention cannot be read: a new recovery replaces it.
139
+ if (error instanceof AuthorizationError && error.code === "RESULT_EXPIRED")
140
+ return undefined;
141
+ throw error;
142
+ }
143
+ }
@@ -0,0 +1,91 @@
1
+ import { type DeviceKey } from "./deviceKey.ts";
2
+ import type { LinkedWallet } from "./linkedWallets.ts";
3
+ import type { DeviceSharePackage, WalletResult } from "./walletOperations.ts";
4
+ /**
5
+ * One wallet's device share as this device keeps it. walletId is the
6
+ * own-wallet system's wallet ID (a linked wallet's externalWalletId),
7
+ * publicKeyHex the device key's compressed public key. A record of a
8
+ * recovery confirmed but not finished carries pendingOperationId and
9
+ * executeKey and no deviceShare: it is no usable share, and the recovery is
10
+ * resumed from it.
11
+ */
12
+ export interface DeviceShareRecord {
13
+ readonly walletId: string;
14
+ readonly privateKey: CryptoKey;
15
+ readonly publicKeyHex?: string;
16
+ readonly deviceShare?: DeviceSharePackage;
17
+ readonly pendingOperationId?: string;
18
+ readonly executeKey?: string;
19
+ /** The linked wallet the pending recovery was admitted for; a reconnection may since have given the wallet another. */
20
+ readonly pendingLinkedWalletId?: string;
21
+ }
22
+ /** A recovery confirmed and kept before it is executed. */
23
+ export type PendingDeviceShareRecord = DeviceShareRecord & {
24
+ publicKeyHex: string;
25
+ pendingOperationId: string;
26
+ executeKey: string;
27
+ deviceShare?: undefined;
28
+ };
29
+ /** Where the records live. load answers undefined for a wallet this device holds no record of. */
30
+ export interface DeviceShareStore {
31
+ load(walletId: string): Promise<DeviceShareRecord | undefined>;
32
+ save(record: DeviceShareRecord): Promise<void>;
33
+ }
34
+ /** Options of a recovery: the passkey that confirms it (by default the account's newest) and the caller's signal. */
35
+ export interface DeviceRecoveryOptions {
36
+ credentialId?: string;
37
+ signal?: AbortSignal;
38
+ }
39
+ /** The provider's device key of a wallet, undefined when it names none; read again each time it is asked. */
40
+ export type ServerDeviceKey = () => Promise<string | undefined>;
41
+ /** What the linked-wallet facade asks of the keeper (linkedWallets.ts). */
42
+ export interface LinkedDeviceShares {
43
+ has(wallet: LinkedWallet, serverKey: ServerDeviceKey): Promise<boolean>;
44
+ /** Makes sure this device holds a usable share of the wallet, recovering it unless one is kept (force: always); answers whether it recovered. */
45
+ ensure(wallet: LinkedWallet, serverKey: ServerDeviceKey, options?: DeviceRecoveryOptions & {
46
+ force?: boolean;
47
+ }): Promise<boolean>;
48
+ /** The kept share resealed to the own-wallet system's transport key for one transfer. */
49
+ sealedFor(wallet: LinkedWallet, transportPublicKey: string): Promise<DeviceSharePackage>;
50
+ }
51
+ /**
52
+ * indexedDBDeviceShareStore keeps the records in the origin's IndexedDB, under
53
+ * namespace (one per client configuration), so the tabs of this origin share
54
+ * them and the key handles stay non-extractable.
55
+ */
56
+ export declare function indexedDBDeviceShareStore(namespace: string, factory?: IDBFactory | undefined): DeviceShareStore;
57
+ /** The store a web client keeps its shares in when the app supplies none: the origin's IndexedDB, where there is one. */
58
+ export declare function defaultDeviceShareStore(channel: "web" | "mobile", namespace: string): DeviceShareStore | undefined;
59
+ /**
60
+ * DeviceShareKeeper is one client's keeper: the store, the lock its tabs take
61
+ * turns under, the device keys of the creations it made, and the recovery the
62
+ * client runs and resumes (deviceRecovery.ts).
63
+ */
64
+ export declare class DeviceShareKeeper implements LinkedDeviceShares {
65
+ #private;
66
+ constructor(input: {
67
+ store: DeviceShareStore | undefined;
68
+ crypto: Crypto;
69
+ lockPrefix: string;
70
+ lock: <T>(name: string, work: () => Promise<T>) => Promise<T>;
71
+ recover: (wallet: LinkedWallet, options: DeviceRecoveryOptions, keepPending: (record: PendingDeviceShareRecord) => Promise<void>) => Promise<DeviceShareRecord>;
72
+ resume: (wallet: LinkedWallet, record: PendingDeviceShareRecord, options: DeviceRecoveryOptions) => Promise<DeviceShareRecord | undefined>;
73
+ });
74
+ /** The device key a creation without the app's own key is sealed to; the same one for every call with the same idempotency key, also at once. */
75
+ creationKey(idempotencyKey: string): Promise<DeviceKey>;
76
+ /** holdCreation remembers which device key the admitted creation's share will be sealed to. */
77
+ holdCreation(operationId: string, device: DeviceKey): void;
78
+ /**
79
+ * keepCreated keeps the share of a creation this keeper made the key for,
80
+ * once its result is read, under the wallet's lock. It never replaces a
81
+ * record of the wallet: one kept already is the creation's own, read before,
82
+ * or a later recovery's, pending or done, which a late read of the
83
+ * creation's result must not undo.
84
+ */
85
+ keepCreated(operationId: string, result: WalletResult["result"]): Promise<void>;
86
+ has(wallet: LinkedWallet, serverKey: ServerDeviceKey): Promise<boolean>;
87
+ ensure(wallet: LinkedWallet, serverKey: ServerDeviceKey, options?: DeviceRecoveryOptions & {
88
+ force?: boolean;
89
+ }): Promise<boolean>;
90
+ sealedFor(wallet: LinkedWallet, transportPublicKey: string): Promise<DeviceSharePackage>;
91
+ }
@@ -0,0 +1,204 @@
1
+ // The device shares this SDK keeps (owner decision of 28 October 2026): for
2
+ // each wallet whose device key the SDK made, at creation or at a recovery, the
3
+ // non-extractable device key and the wallet's device share sealed to it, in
4
+ // the origin's storage. A browser that holds no usable share of a wallet,
5
+ // after a change of domain, cleared site data, another browser or device, or
6
+ // a recovery on another device that replaced its share, recovers it with one
7
+ // passkey confirmation (deviceRecovery.ts) and then sends as before. The
8
+ // provider names the device key of the wallet's latest creation or recovery;
9
+ // a kept share sealed to another key is stale. The tabs of one browser take
10
+ // turns under one Web Lock per wallet, so two of them never recover the same
11
+ // wallet at once. The plaintext share never leaves resealDeviceShare.
12
+ import { ClientFlowError } from "./auth.js";
13
+ import { generateDeviceKey } from "./deviceKey.js";
14
+ import { resealDeviceShare } from "./deviceShare.js";
15
+ const unavailable = () => new ClientFlowError("device_share_unavailable");
16
+ const DATABASE = "streams-authorization-device-shares";
17
+ const STORE = "shares";
18
+ function pending(record) {
19
+ return record !== undefined && record.deviceShare === undefined && typeof record.pendingOperationId === "string" && typeof record.executeKey === "string" && typeof record.publicKeyHex === "string";
20
+ }
21
+ function usable(record) {
22
+ return record !== undefined && record.pendingOperationId === undefined && record.deviceShare !== null && typeof record.deviceShare === "object";
23
+ }
24
+ /** A usable share is current when the provider names no device key, or names the one it is sealed to. */
25
+ function current(record, serverKey) {
26
+ return usable(record) && (serverKey === undefined || record.publicKeyHex === serverKey);
27
+ }
28
+ /**
29
+ * indexedDBDeviceShareStore keeps the records in the origin's IndexedDB, under
30
+ * namespace (one per client configuration), so the tabs of this origin share
31
+ * them and the key handles stay non-extractable.
32
+ */
33
+ export function indexedDBDeviceShareStore(namespace, factory = globalThis.indexedDB) {
34
+ if (!factory || typeof factory.open !== "function")
35
+ throw unavailable();
36
+ const key = (walletId) => `${namespace}|${walletId}`;
37
+ const run = (mode, work) => new Promise((resolve, reject) => {
38
+ const opened = factory.open(DATABASE, 1);
39
+ opened.onupgradeneeded = () => { opened.result.createObjectStore(STORE); };
40
+ opened.onerror = () => reject(unavailable());
41
+ opened.onsuccess = () => {
42
+ const database = opened.result;
43
+ try {
44
+ const transaction = database.transaction(STORE, mode);
45
+ const request = work(transaction.objectStore(STORE));
46
+ transaction.oncomplete = () => { database.close(); resolve(request.result); };
47
+ transaction.onerror = transaction.onabort = () => { database.close(); reject(unavailable()); };
48
+ }
49
+ catch {
50
+ database.close();
51
+ reject(unavailable());
52
+ }
53
+ };
54
+ });
55
+ return {
56
+ async load(walletId) {
57
+ const value = await run("readonly", (store) => store.get(key(walletId)));
58
+ if (value === undefined)
59
+ return undefined;
60
+ const record = value;
61
+ if (record.walletId !== walletId || !(record.privateKey instanceof CryptoKey) || (!usable(record) && !pending(record)))
62
+ throw unavailable();
63
+ return record;
64
+ },
65
+ async save(record) {
66
+ const kept = { walletId: record.walletId, privateKey: record.privateKey };
67
+ if (record.publicKeyHex !== undefined)
68
+ kept.publicKeyHex = record.publicKeyHex;
69
+ if (record.deviceShare !== undefined)
70
+ kept.deviceShare = { ...record.deviceShare };
71
+ if (record.pendingOperationId !== undefined)
72
+ Object.assign(kept, { pendingOperationId: record.pendingOperationId, executeKey: record.executeKey });
73
+ if (record.pendingLinkedWalletId !== undefined)
74
+ kept.pendingLinkedWalletId = record.pendingLinkedWalletId;
75
+ await run("readwrite", (store) => store.put(kept, key(record.walletId)));
76
+ },
77
+ };
78
+ }
79
+ /** The store a web client keeps its shares in when the app supplies none: the origin's IndexedDB, where there is one. */
80
+ export function defaultDeviceShareStore(channel, namespace) {
81
+ if (channel !== "web" || typeof globalThis.indexedDB?.open !== "function")
82
+ return undefined;
83
+ return indexedDBDeviceShareStore(namespace);
84
+ }
85
+ /**
86
+ * DeviceShareKeeper is one client's keeper: the store, the lock its tabs take
87
+ * turns under, the device keys of the creations it made, and the recovery the
88
+ * client runs and resumes (deviceRecovery.ts).
89
+ */
90
+ export class DeviceShareKeeper {
91
+ #store;
92
+ #crypto;
93
+ #lock;
94
+ #lockPrefix;
95
+ #recover;
96
+ #resume;
97
+ /** Device keys of creations, by the creation's idempotency key, so a repeat sends the same request. */
98
+ #creationKeys = new Map();
99
+ /** The creation's device key, by operation, until its result is kept. */
100
+ #creations = new Map();
101
+ /** Recoveries under way in this client, by wallet. */
102
+ #flights = new Map();
103
+ constructor(input) {
104
+ this.#store = input.store;
105
+ this.#crypto = input.crypto;
106
+ this.#lock = input.lock;
107
+ this.#lockPrefix = input.lockPrefix;
108
+ this.#recover = input.recover;
109
+ this.#resume = input.resume;
110
+ }
111
+ #required() {
112
+ if (!this.#store)
113
+ throw unavailable();
114
+ return this.#store;
115
+ }
116
+ /** The device key a creation without the app's own key is sealed to; the same one for every call with the same idempotency key, also at once. */
117
+ creationKey(idempotencyKey) {
118
+ this.#required();
119
+ let made = this.#creationKeys.get(idempotencyKey);
120
+ if (!made) {
121
+ made = generateDeviceKey(this.#crypto.subtle);
122
+ this.#creationKeys.set(idempotencyKey, made);
123
+ made.catch(() => this.#creationKeys.delete(idempotencyKey));
124
+ }
125
+ return made;
126
+ }
127
+ /** holdCreation remembers which device key the admitted creation's share will be sealed to. */
128
+ holdCreation(operationId, device) { this.#creations.set(operationId, device); }
129
+ /**
130
+ * keepCreated keeps the share of a creation this keeper made the key for,
131
+ * once its result is read, under the wallet's lock. It never replaces a
132
+ * record of the wallet: one kept already is the creation's own, read before,
133
+ * or a later recovery's, pending or done, which a late read of the
134
+ * creation's result must not undo.
135
+ */
136
+ async keepCreated(operationId, result) {
137
+ const device = this.#creations.get(operationId);
138
+ if (!device || result.kind !== "wallet_created" || !result.wallet || !result.deviceShare)
139
+ return;
140
+ const store = this.#required(), walletId = result.wallet.walletId, deviceShare = result.deviceShare;
141
+ await this.#lock(this.#lockName(walletId), async () => {
142
+ if ((await store.load(walletId)) === undefined)
143
+ await store.save({ walletId, privateKey: device.privateKey, publicKeyHex: device.publicKeyHex, deviceShare });
144
+ });
145
+ this.#creations.delete(operationId);
146
+ }
147
+ #lockName(walletId) { return `${this.#lockPrefix}:device-recovery:${walletId}`; }
148
+ async has(wallet, serverKey) {
149
+ const record = await this.#required().load(wallet.externalWalletId);
150
+ return usable(record) && current(record, await serverKey());
151
+ }
152
+ ensure(wallet, serverKey, options = {}) {
153
+ const store = this.#required();
154
+ const walletId = wallet.externalWalletId;
155
+ const force = options.force === true;
156
+ const running = force ? undefined : this.#flights.get(walletId);
157
+ if (running)
158
+ return running;
159
+ const flight = (async () => {
160
+ if (!force && current(await store.load(walletId), await serverKey()))
161
+ return false;
162
+ // Under the tabs' lock the store and the provider's key are read again:
163
+ // a tab that waited finds the share the other one recovered, or the
164
+ // recovery it left pending, and recovers nothing new.
165
+ return this.#lock(this.#lockName(walletId), async () => {
166
+ let record = await store.load(walletId);
167
+ let resumed = false;
168
+ if (pending(record)) {
169
+ record = await this.#resume(wallet, record, options);
170
+ if (record) {
171
+ await store.save(record);
172
+ resumed = true;
173
+ }
174
+ }
175
+ // A share kept, or resumed just now, is used only while the provider
176
+ // names its key: a recovery on another device since replaced it.
177
+ if ((resumed || !force) && current(record, await serverKey()))
178
+ return resumed;
179
+ // One recovery, and one more when another device recovered meanwhile;
180
+ // then the call gives up rather than chase the other device.
181
+ for (let attempt = resumed ? 1 : 0; attempt < 2; attempt++) {
182
+ const recovered = await this.#recover(wallet, options, (kept) => store.save(kept));
183
+ if (recovered.walletId !== walletId)
184
+ throw new ClientFlowError("invalid_server_response");
185
+ await store.save(recovered);
186
+ if (current(recovered, await serverKey()))
187
+ return true;
188
+ }
189
+ throw new ClientFlowError("device_recovery_failed");
190
+ });
191
+ })();
192
+ if (!force) {
193
+ this.#flights.set(walletId, flight);
194
+ void flight.then(() => this.#flights.delete(walletId), () => this.#flights.delete(walletId));
195
+ }
196
+ return flight;
197
+ }
198
+ async sealedFor(wallet, transportPublicKey) {
199
+ const record = await this.#required().load(wallet.externalWalletId);
200
+ if (!usable(record))
201
+ throw unavailable();
202
+ return resealDeviceShare(record.privateKey, record.deviceShare, transportPublicKey, this.#crypto);
203
+ }
204
+ }