@shuttle.technology/authorization 0.0.0-stage → 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 (93) hide show
  1. package/CHANGELOG.md +191 -0
  2. package/README.md +1124 -2
  3. package/dist/accountLists.d.ts +28 -0
  4. package/dist/accountLists.js +27 -0
  5. package/dist/aesGcmSiv.d.ts +14 -0
  6. package/dist/aesGcmSiv.js +160 -0
  7. package/dist/auth.d.ts +68 -0
  8. package/dist/auth.js +189 -0
  9. package/dist/balances.d.ts +31 -0
  10. package/dist/balances.js +44 -0
  11. package/dist/canonicalize.d.ts +19 -0
  12. package/dist/canonicalize.js +107 -0
  13. package/dist/client.d.ts +152 -0
  14. package/dist/client.js +781 -0
  15. package/dist/clientTypes.d.ts +56 -0
  16. package/dist/clientTypes.js +5 -0
  17. package/dist/configuration.d.ts +48 -0
  18. package/dist/configuration.js +72 -0
  19. package/dist/consentDebit.d.ts +36 -0
  20. package/dist/consentDebit.js +95 -0
  21. package/dist/credentials.d.ts +116 -0
  22. package/dist/credentials.js +315 -0
  23. package/dist/debitPermissionWire.d.ts +90 -0
  24. package/dist/debitPermissionWire.js +118 -0
  25. package/dist/debitPermissions.d.ts +39 -0
  26. package/dist/debitPermissions.js +117 -0
  27. package/dist/deviceKey.d.ts +13 -0
  28. package/dist/deviceKey.js +28 -0
  29. package/dist/deviceRecovery.d.ts +40 -0
  30. package/dist/deviceRecovery.js +143 -0
  31. package/dist/deviceShare.d.ts +9 -0
  32. package/dist/deviceShare.js +24 -0
  33. package/dist/deviceShareEnvelope.d.ts +13 -0
  34. package/dist/deviceShareEnvelope.js +139 -0
  35. package/dist/deviceShares.d.ts +91 -0
  36. package/dist/deviceShares.js +204 -0
  37. package/dist/errors.d.ts +280 -0
  38. package/dist/errors.js +85 -0
  39. package/dist/linkedChecks.d.ts +17 -0
  40. package/dist/linkedChecks.js +31 -0
  41. package/dist/linkedRecovery.d.ts +40 -0
  42. package/dist/linkedRecovery.js +49 -0
  43. package/dist/linkedRegistrations.d.ts +11 -0
  44. package/dist/linkedRegistrations.js +109 -0
  45. package/dist/linkedSendDisplay.d.ts +73 -0
  46. package/dist/linkedSendDisplay.js +84 -0
  47. package/dist/linkedWalletSignals.d.ts +22 -0
  48. package/dist/linkedWalletSignals.js +101 -0
  49. package/dist/linkedWallets.d.ts +401 -0
  50. package/dist/linkedWallets.js +436 -0
  51. package/dist/me.d.ts +27 -0
  52. package/dist/me.js +30 -0
  53. package/dist/mediatedOperation.d.ts +27 -0
  54. package/dist/mediatedOperation.js +68 -0
  55. package/dist/mobile.d.ts +64 -0
  56. package/dist/mobile.js +69 -0
  57. package/dist/modeC.d.ts +131 -0
  58. package/dist/modeC.js +248 -0
  59. package/dist/modeCLinked.d.ts +207 -0
  60. package/dist/modeCLinked.js +270 -0
  61. package/dist/pages.d.ts +22 -0
  62. package/dist/pages.js +45 -0
  63. package/dist/poll.d.ts +15 -0
  64. package/dist/poll.js +76 -0
  65. package/dist/redirects.d.ts +45 -0
  66. package/dist/redirects.js +108 -0
  67. package/dist/requestGrant.d.ts +169 -0
  68. package/dist/requestGrant.js +243 -0
  69. package/dist/sends.d.ts +89 -0
  70. package/dist/sends.js +127 -0
  71. package/dist/serviceGrants.d.ts +52 -0
  72. package/dist/serviceGrants.js +46 -0
  73. package/dist/slice4.d.ts +187 -0
  74. package/dist/slice4.js +299 -0
  75. package/dist/streams.d.ts +212 -0
  76. package/dist/streams.js +282 -0
  77. package/dist/trackedWallets.d.ts +53 -0
  78. package/dist/trackedWallets.js +104 -0
  79. package/dist/transferStatus.d.ts +45 -0
  80. package/dist/transferStatus.js +43 -0
  81. package/dist/walletCreateGrant.d.ts +62 -0
  82. package/dist/walletCreateGrant.js +89 -0
  83. package/dist/walletDisplayParts.d.ts +17 -0
  84. package/dist/walletDisplayParts.js +47 -0
  85. package/dist/walletEvents.d.ts +43 -0
  86. package/dist/walletEvents.js +97 -0
  87. package/dist/walletLimits.d.ts +65 -0
  88. package/dist/walletLimits.js +102 -0
  89. package/dist/walletOperations.d.ts +209 -0
  90. package/dist/walletOperations.js +317 -0
  91. package/dist/webRenewal.d.ts +92 -0
  92. package/dist/webRenewal.js +179 -0
  93. package/package.json +65 -3
@@ -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,13 @@
1
+ export interface DeviceKey {
2
+ /** Non-extractable ECDH P-256 private key handle. */
3
+ readonly privateKey: CryptoKey;
4
+ /** 33-byte compressed public key, 66 lowercase hexadecimal characters. */
5
+ readonly publicKeyHex: string;
6
+ }
7
+ /** generateDeviceKey creates one device key pair with a non-extractable private key. */
8
+ export declare function generateDeviceKey(subtle?: SubtleCrypto): Promise<DeviceKey>;
9
+ /**
10
+ * devicePublicKeyHex returns the compressed SEC1 encoding of a P-256 public
11
+ * key: the parity prefix 02 or 03 followed by the 32-byte X coordinate.
12
+ */
13
+ export declare function devicePublicKeyHex(publicKey: CryptoKey, subtle?: SubtleCrypto): Promise<string>;
@@ -0,0 +1,28 @@
1
+ // Device key (source §1.10): the P-256 key pair the own-wallet system seals a
2
+ // wallet's device share to. The private key is generated non-extractable and
3
+ // never leaves the device; only its compressed public key is sent, as the
4
+ // recipient key of a wallet.create operation. This module performs no
5
+ // persistence and no network request: the application keeps the returned
6
+ // private key handle and the sealed device share it later receives in platform
7
+ // storage that can hold a non-extractable key, such as IndexedDB.
8
+ import { ClientFlowError } from "./auth.js";
9
+ /** generateDeviceKey creates one device key pair with a non-extractable private key. */
10
+ export async function generateDeviceKey(subtle = globalThis.crypto.subtle) {
11
+ const pair = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
12
+ if (pair.privateKey.extractable)
13
+ throw new ClientFlowError("device_key_unavailable");
14
+ return { privateKey: pair.privateKey, publicKeyHex: await devicePublicKeyHex(pair.publicKey, subtle) };
15
+ }
16
+ /**
17
+ * devicePublicKeyHex returns the compressed SEC1 encoding of a P-256 public
18
+ * key: the parity prefix 02 or 03 followed by the 32-byte X coordinate.
19
+ */
20
+ export async function devicePublicKeyHex(publicKey, subtle = globalThis.crypto.subtle) {
21
+ const raw = new Uint8Array(await subtle.exportKey("raw", publicKey));
22
+ if (raw.length !== 65 || raw[0] !== 0x04)
23
+ throw new ClientFlowError("device_key_unavailable");
24
+ const compressed = new Uint8Array(33);
25
+ compressed[0] = 0x02 | (raw[64] & 0x01);
26
+ compressed.set(raw.subarray(1, 33), 1);
27
+ return Array.from(compressed, (byte) => byte.toString(16).padStart(2, "0")).join("");
28
+ }
@@ -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,9 @@
1
+ import type { DeviceSharePackage } from "./walletOperations.ts";
2
+ export { DeviceShareAlgorithm } from "./deviceShareEnvelope.ts";
3
+ /**
4
+ * resealDeviceShare opens the device share with the device key and seals it,
5
+ * under its own unchanged context, to the enclave's transport key for one
6
+ * send. The plaintext share exists only inside this call. The transport key
7
+ * is taken as given: this function cannot tell an enclave's key from any other.
8
+ */
9
+ export declare function resealDeviceShare(deviceKey: CryptoKey, sealed: DeviceSharePackage, enclavePublicKeyHex: string, crypto?: Crypto): Promise<DeviceSharePackage>;
@@ -0,0 +1,24 @@
1
+ // Device share (source §1.10): the one of a wallet's three key shares that only
2
+ // the user's device can open. The own-wallet system seals it to the device key
3
+ // when the wallet is created. To sign, the device opens it and seals it again
4
+ // to the enclave's transport key, so the enclave can combine it with its own
5
+ // share. This module is the whole of what an application needs for that, and
6
+ // it never hands the plaintext share to its caller. It performs no persistence
7
+ // and no network request.
8
+ import { openDeviceShare, sealDeviceShare } from "./deviceShareEnvelope.js";
9
+ export { DeviceShareAlgorithm } from "./deviceShareEnvelope.js";
10
+ /**
11
+ * resealDeviceShare opens the device share with the device key and seals it,
12
+ * under its own unchanged context, to the enclave's transport key for one
13
+ * send. The plaintext share exists only inside this call. The transport key
14
+ * is taken as given: this function cannot tell an enclave's key from any other.
15
+ */
16
+ export async function resealDeviceShare(deviceKey, sealed, enclavePublicKeyHex, crypto = globalThis.crypto) {
17
+ const share = await openDeviceShare(deviceKey, sealed, crypto.subtle);
18
+ try {
19
+ return await sealDeviceShare(share, enclavePublicKeyHex, sealed.encryptionContext, crypto);
20
+ }
21
+ finally {
22
+ share.fill(0);
23
+ }
24
+ }
@@ -0,0 +1,13 @@
1
+ import { type Bytes } from "./aesGcmSiv.ts";
2
+ import type { DeviceSharePackage } from "./walletOperations.ts";
3
+ export declare const DeviceShareAlgorithm = "p256-ecdh-hkdf-sha256-aes256-gcm-siv-v1";
4
+ /**
5
+ * uncompressedPoint returns the 65-byte form WebCrypto imports, from the
6
+ * 33-byte compressed or the 65-byte uncompressed SEC1 encoding of a point of
7
+ * P-256. Anything that is not a point of the curve is refused here.
8
+ */
9
+ export declare function uncompressedPoint(encoded: Uint8Array): Bytes;
10
+ /** openDeviceShare returns the plaintext share; the caller clears it after use. */
11
+ export declare function openDeviceShare(privateKey: CryptoKey, sealed: DeviceSharePackage, subtle?: SubtleCrypto): Promise<Bytes>;
12
+ /** sealDeviceShare seals a share to a recipient's P-256 key under the given context. */
13
+ export declare function sealDeviceShare(share: Uint8Array, recipientPublicKeyHex: string, contextHex: string, crypto?: Crypto): Promise<DeviceSharePackage>;
@@ -0,0 +1,139 @@
1
+ // The device share envelope, in the own-wallet system's format byte for byte
2
+ // (enclave device_crypto at Wallet source 9160f84): ECDH over P-256 with an
3
+ // ephemeral key, HKDF-SHA-256 with the encryption context as salt and the info
4
+ // "novasol:device-share:v1", AES-256-GCM-SIV with the same context as
5
+ // associated data. openDeviceShare returns the plaintext share, so this module
6
+ // is not a package entry point: applications use deviceShare.ts, which never
7
+ // lets the plaintext out.
8
+ import { ClientFlowError } from "./auth.js";
9
+ import { aes256GcmSivOpen, aes256GcmSivSeal } from "./aesGcmSiv.js";
10
+ export const DeviceShareAlgorithm = "p256-ecdh-hkdf-sha256-aes256-gcm-siv-v1";
11
+ const INFO = new TextEncoder().encode("novasol:device-share:v1");
12
+ const TAG_BYTES = 16;
13
+ // What an opener accepts, so a sealer never produces what cannot be opened.
14
+ const MAX_CIPHERTEXT_BYTES = 4096;
15
+ const MAX_CONTEXT_BYTES = 512;
16
+ // The P-256 field prime and curve constant b; a is -3.
17
+ const P = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn;
18
+ const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;
19
+ function fault() { throw new ClientFlowError("device_share_unavailable"); }
20
+ function fromHex(value, minimum, maximum) {
21
+ if (typeof value !== "string" || value.length % 2 !== 0 || !/^[0-9a-f]*$/.test(value) || value.length < 2 * minimum || value.length > 2 * maximum)
22
+ fault();
23
+ const out = new Uint8Array(value.length / 2);
24
+ for (let index = 0; index < out.length; index++)
25
+ out[index] = Number.parseInt(value.slice(2 * index, 2 * index + 2), 16);
26
+ return out;
27
+ }
28
+ function toHex(bytes) {
29
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
30
+ }
31
+ // power and curveSquare work on public coordinates only, never on a secret.
32
+ function power(base, exponent) {
33
+ let result = 1n;
34
+ for (let value = base % P, rest = exponent; rest > 0n; rest >>= 1n, value = (value * value) % P)
35
+ if (rest & 1n)
36
+ result = (result * value) % P;
37
+ return result;
38
+ }
39
+ function curveSquare(x) { return (power(x, 3n) + P - ((3n * x) % P) + B) % P; }
40
+ function bigEndian(bytes) {
41
+ let value = 0n;
42
+ for (const byte of bytes)
43
+ value = (value << 8n) | BigInt(byte);
44
+ return value;
45
+ }
46
+ /**
47
+ * uncompressedPoint returns the 65-byte form WebCrypto imports, from the
48
+ * 33-byte compressed or the 65-byte uncompressed SEC1 encoding of a point of
49
+ * P-256. Anything that is not a point of the curve is refused here.
50
+ */
51
+ export function uncompressedPoint(encoded) {
52
+ if (encoded.length === 65 && encoded[0] === 0x04) {
53
+ const x = bigEndian(encoded.subarray(1, 33));
54
+ const y = bigEndian(encoded.subarray(33));
55
+ if (x >= P || y >= P || (y * y) % P !== curveSquare(x))
56
+ fault();
57
+ return new Uint8Array(encoded);
58
+ }
59
+ if (encoded.length !== 33 || (encoded[0] !== 0x02 && encoded[0] !== 0x03))
60
+ fault();
61
+ const x = bigEndian(encoded.subarray(1));
62
+ if (x >= P)
63
+ fault();
64
+ const square = curveSquare(x);
65
+ let y = power(square, (P + 1n) / 4n);
66
+ if ((y * y) % P !== square)
67
+ fault();
68
+ if (Number(y & 1n) !== (encoded[0] & 1))
69
+ y = P - y;
70
+ const out = new Uint8Array(65);
71
+ out[0] = 0x04;
72
+ out.set(encoded.subarray(1), 1);
73
+ for (let index = 0; index < 32; index++)
74
+ out[64 - index] = Number((y >> BigInt(8 * index)) & 0xffn);
75
+ return out;
76
+ }
77
+ function compressedPoint(uncompressed) {
78
+ if (uncompressed.length !== 65 || uncompressed[0] !== 0x04)
79
+ fault();
80
+ const out = new Uint8Array(33);
81
+ out[0] = 0x02 | (uncompressed[64] & 0x01);
82
+ out.set(uncompressed.subarray(1, 33), 1);
83
+ return out;
84
+ }
85
+ /** The envelope key: the ECDH shared X coordinate through HKDF-SHA-256, salted with the context. */
86
+ async function envelopeKey(subtle, privateKey, peer, context) {
87
+ const point = uncompressedPoint(peer);
88
+ let shared;
89
+ try {
90
+ const publicKey = await subtle.importKey("raw", point, { name: "ECDH", namedCurve: "P-256" }, false, []);
91
+ shared = new Uint8Array(await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256));
92
+ const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveBits"]);
93
+ return new Uint8Array(await subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: context, info: INFO }, material, 256));
94
+ }
95
+ catch {
96
+ // One answer for every refusal, whatever the platform's own error was.
97
+ return fault();
98
+ }
99
+ finally {
100
+ shared?.fill(0);
101
+ }
102
+ }
103
+ /** openDeviceShare returns the plaintext share; the caller clears it after use. */
104
+ export async function openDeviceShare(privateKey, sealed, subtle = globalThis.crypto.subtle) {
105
+ if (sealed.encryptionAlgorithm !== DeviceShareAlgorithm)
106
+ fault();
107
+ const context = fromHex(sealed.encryptionContext, 1, MAX_CONTEXT_BYTES);
108
+ const ciphertext = fromHex(sealed.ciphertext, TAG_BYTES + 1, MAX_CIPHERTEXT_BYTES);
109
+ const nonce = fromHex(sealed.nonce, 12, 12);
110
+ const key = await envelopeKey(subtle, privateKey, fromHex(sealed.ephemeralPublicKey, 33, 65), context);
111
+ try {
112
+ return await aes256GcmSivOpen(subtle, key, nonce, ciphertext, context);
113
+ }
114
+ catch {
115
+ return fault();
116
+ }
117
+ finally {
118
+ key.fill(0);
119
+ }
120
+ }
121
+ /** sealDeviceShare seals a share to a recipient's P-256 key under the given context. */
122
+ export async function sealDeviceShare(share, recipientPublicKeyHex, contextHex, crypto = globalThis.crypto) {
123
+ if (share.length === 0 || share.length > MAX_CIPHERTEXT_BYTES - TAG_BYTES)
124
+ fault();
125
+ const subtle = crypto.subtle;
126
+ const context = fromHex(contextHex, 1, MAX_CONTEXT_BYTES);
127
+ const recipient = fromHex(recipientPublicKeyHex, 33, 65);
128
+ const ephemeral = await subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
129
+ const key = await envelopeKey(subtle, ephemeral.privateKey, recipient, context);
130
+ try {
131
+ const nonce = crypto.getRandomValues(new Uint8Array(12));
132
+ const ciphertext = await aes256GcmSivSeal(subtle, key, nonce, share, context);
133
+ const ephemeralPublic = compressedPoint(new Uint8Array(await subtle.exportKey("raw", ephemeral.publicKey)));
134
+ return { ciphertext: toHex(ciphertext), nonce: toHex(nonce), ephemeralPublicKey: toHex(ephemeralPublic), encryptionAlgorithm: DeviceShareAlgorithm, encryptionContext: contextHex };
135
+ }
136
+ finally {
137
+ key.fill(0);
138
+ }
139
+ }
@@ -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
+ }