@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.
- package/CHANGELOG.md +191 -0
- package/README.md +1124 -2
- package/dist/accountLists.d.ts +28 -0
- package/dist/accountLists.js +27 -0
- package/dist/aesGcmSiv.d.ts +14 -0
- package/dist/aesGcmSiv.js +160 -0
- package/dist/auth.d.ts +68 -0
- package/dist/auth.js +189 -0
- package/dist/balances.d.ts +31 -0
- package/dist/balances.js +44 -0
- package/dist/canonicalize.d.ts +19 -0
- package/dist/canonicalize.js +107 -0
- package/dist/client.d.ts +152 -0
- package/dist/client.js +781 -0
- package/dist/clientTypes.d.ts +56 -0
- package/dist/clientTypes.js +5 -0
- package/dist/configuration.d.ts +48 -0
- package/dist/configuration.js +72 -0
- package/dist/consentDebit.d.ts +36 -0
- package/dist/consentDebit.js +95 -0
- package/dist/credentials.d.ts +116 -0
- package/dist/credentials.js +315 -0
- package/dist/debitPermissionWire.d.ts +90 -0
- package/dist/debitPermissionWire.js +118 -0
- package/dist/debitPermissions.d.ts +39 -0
- package/dist/debitPermissions.js +117 -0
- package/dist/deviceKey.d.ts +13 -0
- package/dist/deviceKey.js +28 -0
- package/dist/deviceRecovery.d.ts +40 -0
- package/dist/deviceRecovery.js +143 -0
- package/dist/deviceShare.d.ts +9 -0
- package/dist/deviceShare.js +24 -0
- package/dist/deviceShareEnvelope.d.ts +13 -0
- package/dist/deviceShareEnvelope.js +139 -0
- package/dist/deviceShares.d.ts +91 -0
- package/dist/deviceShares.js +204 -0
- package/dist/errors.d.ts +280 -0
- package/dist/errors.js +85 -0
- package/dist/linkedChecks.d.ts +17 -0
- package/dist/linkedChecks.js +31 -0
- package/dist/linkedRecovery.d.ts +40 -0
- package/dist/linkedRecovery.js +49 -0
- package/dist/linkedRegistrations.d.ts +11 -0
- package/dist/linkedRegistrations.js +109 -0
- package/dist/linkedSendDisplay.d.ts +73 -0
- package/dist/linkedSendDisplay.js +84 -0
- package/dist/linkedWalletSignals.d.ts +22 -0
- package/dist/linkedWalletSignals.js +101 -0
- package/dist/linkedWallets.d.ts +401 -0
- package/dist/linkedWallets.js +436 -0
- package/dist/me.d.ts +27 -0
- package/dist/me.js +30 -0
- package/dist/mediatedOperation.d.ts +27 -0
- package/dist/mediatedOperation.js +68 -0
- package/dist/mobile.d.ts +64 -0
- package/dist/mobile.js +69 -0
- package/dist/modeC.d.ts +131 -0
- package/dist/modeC.js +248 -0
- package/dist/modeCLinked.d.ts +207 -0
- package/dist/modeCLinked.js +270 -0
- package/dist/pages.d.ts +22 -0
- package/dist/pages.js +45 -0
- package/dist/poll.d.ts +15 -0
- package/dist/poll.js +76 -0
- package/dist/redirects.d.ts +45 -0
- package/dist/redirects.js +108 -0
- package/dist/requestGrant.d.ts +169 -0
- package/dist/requestGrant.js +243 -0
- package/dist/sends.d.ts +89 -0
- package/dist/sends.js +127 -0
- package/dist/serviceGrants.d.ts +52 -0
- package/dist/serviceGrants.js +46 -0
- package/dist/slice4.d.ts +187 -0
- package/dist/slice4.js +299 -0
- package/dist/streams.d.ts +212 -0
- package/dist/streams.js +282 -0
- package/dist/trackedWallets.d.ts +53 -0
- package/dist/trackedWallets.js +104 -0
- package/dist/transferStatus.d.ts +45 -0
- package/dist/transferStatus.js +43 -0
- package/dist/walletCreateGrant.d.ts +62 -0
- package/dist/walletCreateGrant.js +89 -0
- package/dist/walletDisplayParts.d.ts +17 -0
- package/dist/walletDisplayParts.js +47 -0
- package/dist/walletEvents.d.ts +43 -0
- package/dist/walletEvents.js +97 -0
- package/dist/walletLimits.d.ts +65 -0
- package/dist/walletLimits.js +102 -0
- package/dist/walletOperations.d.ts +209 -0
- package/dist/walletOperations.js +317 -0
- package/dist/webRenewal.d.ts +92 -0
- package/dist/webRenewal.js +179 -0
- package/package.json +65 -3
package/dist/mobile.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export interface MobileSecureStorage {
|
|
2
|
+
get(key: string): Promise<string | null>;
|
|
3
|
+
set(key: string, value: string): Promise<void>;
|
|
4
|
+
delete(key: string): Promise<void>;
|
|
5
|
+
}
|
|
6
|
+
export interface MobileAssociationInput {
|
|
7
|
+
redirectUri: string;
|
|
8
|
+
applicationIdentifier: string;
|
|
9
|
+
}
|
|
10
|
+
export interface MobileAssociationVerification {
|
|
11
|
+
source: "os_verified_https_association";
|
|
12
|
+
redirectUri: string;
|
|
13
|
+
applicationIdentifier: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Host/native trust boundary. Implementations must ask the operating system to
|
|
17
|
+
* verify the configured HTTPS app/universal-link association. On iOS, where no
|
|
18
|
+
* API reports that verification, the system makes it when an authentication
|
|
19
|
+
* session with an HTTPS callback starts, and refuses to start one for an app
|
|
20
|
+
* the domain does not name; a bridge whose openSystemBrowser opens exactly
|
|
21
|
+
* that session answers for it. This result is a port contract, not
|
|
22
|
+
* cryptographic proof produced by this TypeScript package.
|
|
23
|
+
*/
|
|
24
|
+
export interface MobileBrowserBridge {
|
|
25
|
+
verifyAssociatedRedirect(input: MobileAssociationInput): Promise<MobileAssociationVerification | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Opens the URL in the system browser's authentication session
|
|
28
|
+
* (ASWebAuthenticationSession on iOS, a Custom Tab on Android), never an
|
|
29
|
+
* embedded web view, and resolves with the URL that session ended on: the
|
|
30
|
+
* registered app link the provider redirected back to. It resolves with
|
|
31
|
+
* nothing when no app link came back, for example when the person closed
|
|
32
|
+
* the browser. A sign-in needs that URL; the other hosted flows take it
|
|
33
|
+
* from the host through their `complete…` method.
|
|
34
|
+
*/
|
|
35
|
+
openSystemBrowser(url: string): Promise<string | void> | string | void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* verifyMobileAssociation asks the host to have the operating system verify
|
|
39
|
+
* the app link of the exact configured redirect for the configured app, and
|
|
40
|
+
* accepts only the exact answer for both. It runs when the client is
|
|
41
|
+
* initialized and again before every sign-in opens the system browser.
|
|
42
|
+
*/
|
|
43
|
+
export declare function verifyMobileAssociation(bridge: MobileBrowserBridge, redirectUri: string, applicationIdentifier: string): Promise<void>;
|
|
44
|
+
/** mobileBridgeComplete checks that the host supplied every mobile port. */
|
|
45
|
+
export declare function mobileBridgeComplete(bridge: MobileBrowserBridge | undefined, storage: MobileSecureStorage | undefined): bridge is MobileBrowserBridge;
|
|
46
|
+
/** mobileApplicationIdentifier is a bundle or package identifier as configuration names it. */
|
|
47
|
+
export declare function mobileApplicationIdentifier(value: unknown): value is string;
|
|
48
|
+
/**
|
|
49
|
+
* mobileSignInReturn reads what the system browser handed back from a hosted
|
|
50
|
+
* sign-in: the exact registered redirect with the one-use code and this
|
|
51
|
+
* sign-in's own state, and nothing else. No app link back, a redirect of
|
|
52
|
+
* another sign-in or an attempt's redirect is refused before any exchange.
|
|
53
|
+
*/
|
|
54
|
+
export declare function mobileSignInReturn(returned: string | void, redirectUri: string, state: string): {
|
|
55
|
+
code: string;
|
|
56
|
+
state: string;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* mobileRefreshKey names the refresh token of one public client in the secure
|
|
60
|
+
* storage. Its parts are opaque identifiers, which hold no dot, so the key is
|
|
61
|
+
* unambiguous, and it uses only letters, digits, dots, hyphens and
|
|
62
|
+
* underscores, which every platform keystore takes as a key.
|
|
63
|
+
*/
|
|
64
|
+
export declare function mobileRefreshKey(tenantId: string, applicationId: string, environmentId: string, clientId: string): string;
|
package/dist/mobile.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// The mobile host boundary: the ports a native host supplies (the system
|
|
2
|
+
// browser with its verified app link, and the OS secure storage that holds the
|
|
3
|
+
// refresh token), the check of the app link the host reports, and the return
|
|
4
|
+
// of a sign-in from the system browser's authentication session.
|
|
5
|
+
//
|
|
6
|
+
// A mobile app sends no browser Origin. The provider binds its sign-in to the
|
|
7
|
+
// exact registered redirect, an HTTPS app link (iOS universal link, Android
|
|
8
|
+
// app link) on one of the client's registered origins, and to the state and
|
|
9
|
+
// the S256 verifier this client keeps in memory. The browser comes back to the
|
|
10
|
+
// app with the one-use code and the state alone; the access token stays in
|
|
11
|
+
// memory and the refresh token goes only to `MobileSecureStorage`.
|
|
12
|
+
import { ClientFlowError } from "./auth.js";
|
|
13
|
+
import { handoffCallback } from "./redirects.js";
|
|
14
|
+
/**
|
|
15
|
+
* verifyMobileAssociation asks the host to have the operating system verify
|
|
16
|
+
* the app link of the exact configured redirect for the configured app, and
|
|
17
|
+
* accepts only the exact answer for both. It runs when the client is
|
|
18
|
+
* initialized and again before every sign-in opens the system browser.
|
|
19
|
+
*/
|
|
20
|
+
export async function verifyMobileAssociation(bridge, redirectUri, applicationIdentifier) {
|
|
21
|
+
let verification;
|
|
22
|
+
try {
|
|
23
|
+
verification = await bridge.verifyAssociatedRedirect({ redirectUri, applicationIdentifier });
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new ClientFlowError("mobile_association_unverified");
|
|
27
|
+
}
|
|
28
|
+
if (!exactMobileAssociation(verification, redirectUri, applicationIdentifier))
|
|
29
|
+
throw new ClientFlowError("mobile_association_unverified");
|
|
30
|
+
}
|
|
31
|
+
/** mobileBridgeComplete checks that the host supplied every mobile port. */
|
|
32
|
+
export function mobileBridgeComplete(bridge, storage) {
|
|
33
|
+
return Boolean(storage) && Boolean(bridge) && typeof bridge?.verifyAssociatedRedirect === "function" && typeof bridge?.openSystemBrowser === "function";
|
|
34
|
+
}
|
|
35
|
+
/** mobileApplicationIdentifier is a bundle or package identifier as configuration names it. */
|
|
36
|
+
export function mobileApplicationIdentifier(value) {
|
|
37
|
+
return typeof value === "string" && /^[A-Za-z0-9._-]{1,255}$/.test(value);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* mobileSignInReturn reads what the system browser handed back from a hosted
|
|
41
|
+
* sign-in: the exact registered redirect with the one-use code and this
|
|
42
|
+
* sign-in's own state, and nothing else. No app link back, a redirect of
|
|
43
|
+
* another sign-in or an attempt's redirect is refused before any exchange.
|
|
44
|
+
*/
|
|
45
|
+
export function mobileSignInReturn(returned, redirectUri, state) {
|
|
46
|
+
if (typeof returned !== "string")
|
|
47
|
+
throw new ClientFlowError("mobile_redirect_unavailable");
|
|
48
|
+
const callback = handoffCallback(returned, redirectUri);
|
|
49
|
+
if (callback.attemptId !== undefined)
|
|
50
|
+
throw new ClientFlowError("invalid_callback");
|
|
51
|
+
if (callback.state !== state)
|
|
52
|
+
throw new ClientFlowError("state_mismatch");
|
|
53
|
+
return { code: callback.code, state: callback.state };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* mobileRefreshKey names the refresh token of one public client in the secure
|
|
57
|
+
* storage. Its parts are opaque identifiers, which hold no dot, so the key is
|
|
58
|
+
* unambiguous, and it uses only letters, digits, dots, hyphens and
|
|
59
|
+
* underscores, which every platform keystore takes as a key.
|
|
60
|
+
*/
|
|
61
|
+
export function mobileRefreshKey(tenantId, applicationId, environmentId, clientId) {
|
|
62
|
+
return ["authorization", tenantId, applicationId, environmentId, clientId, "refresh"].join(".");
|
|
63
|
+
}
|
|
64
|
+
function exactMobileAssociation(value, redirectUri, applicationIdentifier) {
|
|
65
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
66
|
+
return false;
|
|
67
|
+
const result = value;
|
|
68
|
+
return Object.keys(result).length === 3 && result.source === "os_verified_https_association" && result.redirectUri === redirectUri && result.applicationIdentifier === applicationIdentifier;
|
|
69
|
+
}
|
package/dist/modeC.d.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { type PairedRequestGrant } from "./requestGrant.ts";
|
|
2
|
+
export { canonicalJSON, canonicalJSONBytes, sha256HexLower, MaxCanonicalDepth, type CanonicalValue, } from "./canonicalize.ts";
|
|
3
|
+
export { MaxRequestGrantLifetimeSeconds, MaxRequestGrantRecipients, RequestGrantStore, assertGrantMatches, buildRequestGrantEnrollment, enrollRequestGrant, generateRequestSigningKey, jwkThumbprint, parseRequestGrantResult, parseRequestGrantResultEnvelope, type PairedRequestGrant, type RequestGrantBinding, type RequestGrantBounds, type RequestGrantEnrollment, type RequestGrantEnrollmentCreate, type RequestGrantEnrollmentHost, type RequestGrantResult, type RequestPublicJwk, type RequestSigningKey, } from "./requestGrant.ts";
|
|
4
|
+
/** ModeCProofVersion, Audience, Type and Kind are the closed proof constants of source §2.5. */
|
|
5
|
+
export declare const ModeCProofVersion = "1.0";
|
|
6
|
+
export declare const ModeCProofAudience = "authorization-api:mode-c";
|
|
7
|
+
export declare const ModeCProofType = "auth-user-request+jws";
|
|
8
|
+
export declare const ModeCProofKind = "wallet.send";
|
|
9
|
+
/** ModeCProofPayloadMembers is the exact member count of the signed payload. */
|
|
10
|
+
export declare const ModeCProofPayloadMembers = 17;
|
|
11
|
+
/** MaxModeCProofLifetimeSeconds bounds exp-iat. No grace extends it. */
|
|
12
|
+
export declare const MaxModeCProofLifetimeSeconds = 60;
|
|
13
|
+
/** ModeCJtiBytes is the 256-bit jti entropy that is also the Idempotency-Key. */
|
|
14
|
+
export declare const ModeCJtiBytes = 32;
|
|
15
|
+
/**
|
|
16
|
+
* MediatedTransferIntent is everything the user chooses. The owner, wallet and
|
|
17
|
+
* network are never caller-supplied: they come from the grant binding, so a
|
|
18
|
+
* backend cannot redirect the transfer by naming another wallet.
|
|
19
|
+
*/
|
|
20
|
+
export interface MediatedTransferIntent {
|
|
21
|
+
readonly recipient: string;
|
|
22
|
+
readonly amountRaw: string;
|
|
23
|
+
readonly maxFeeRaw: string;
|
|
24
|
+
}
|
|
25
|
+
/** MediatedTransferDisplay is the exact typed intent that must be rendered before signing. */
|
|
26
|
+
export interface MediatedTransferDisplay {
|
|
27
|
+
readonly kind: "wallet.send";
|
|
28
|
+
readonly ownerId: string;
|
|
29
|
+
readonly walletId: string;
|
|
30
|
+
readonly network: string;
|
|
31
|
+
readonly asset: "native";
|
|
32
|
+
readonly recipient: string;
|
|
33
|
+
readonly amountRaw: string;
|
|
34
|
+
readonly maxFeeRaw: string;
|
|
35
|
+
}
|
|
36
|
+
/** MediatedOperationCreate is the closed mediated OperationCreate of source §2.2. */
|
|
37
|
+
export interface MediatedOperationCreate {
|
|
38
|
+
readonly kind: "wallet.send";
|
|
39
|
+
readonly owner_type: "account";
|
|
40
|
+
readonly owner_id: string;
|
|
41
|
+
readonly wallet_id: string;
|
|
42
|
+
readonly payload: {
|
|
43
|
+
readonly network: string;
|
|
44
|
+
readonly asset: "native";
|
|
45
|
+
readonly recipient: string;
|
|
46
|
+
readonly amount_raw: string;
|
|
47
|
+
readonly max_fee_raw: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** PreparedMediatedTransfer is one displayed intent and the exact body it will sign. */
|
|
51
|
+
export interface PreparedMediatedTransfer {
|
|
52
|
+
readonly display: MediatedTransferDisplay;
|
|
53
|
+
readonly operation: MediatedOperationCreate;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* SignedMediatedRequest is everything the caller hands to its own customer
|
|
57
|
+
* backend. `idempotencyKey` is the payload `jti` itself, so no other request
|
|
58
|
+
* identity can be bound to this proof.
|
|
59
|
+
*/
|
|
60
|
+
export interface SignedMediatedRequest {
|
|
61
|
+
readonly display: MediatedTransferDisplay;
|
|
62
|
+
readonly operation: MediatedOperationCreate;
|
|
63
|
+
readonly userProof: string;
|
|
64
|
+
readonly idempotencyKey: string;
|
|
65
|
+
readonly jti: string;
|
|
66
|
+
readonly requestHash: string;
|
|
67
|
+
readonly issuedAt: number;
|
|
68
|
+
readonly expiresAt: number;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* prepareMediatedTransfer derives the displayed intent and the exact closed
|
|
72
|
+
* OperationCreate from the paired grant. It refuses locally before any request
|
|
73
|
+
* or signature: a recipient outside the frozen allowlist, a noncanonical or
|
|
74
|
+
* over-ceiling quantity and a zero amount all stop here.
|
|
75
|
+
*/
|
|
76
|
+
export declare function prepareMediatedTransfer(paired: PairedRequestGrant, intent: MediatedTransferIntent): PreparedMediatedTransfer;
|
|
77
|
+
/**
|
|
78
|
+
* canonicalMediatedRequest is the RFC 8785 serialization the request hash
|
|
79
|
+
* covers. It is produced from the SDK's own closed body, never from transport
|
|
80
|
+
* bytes.
|
|
81
|
+
*/
|
|
82
|
+
export declare function canonicalMediatedRequest(operation: MediatedOperationCreate): string;
|
|
83
|
+
/** mediatedRequestHash is the lowercase hex SHA-256 of the canonical request bytes. */
|
|
84
|
+
export declare function mediatedRequestHash(crypto: Crypto, operation: MediatedOperationCreate): Promise<string>;
|
|
85
|
+
/**
|
|
86
|
+
* modeCProtectedHeader is the closed protected header of source §2.5 in its
|
|
87
|
+
* exact member order. The header is not canonicalized: `alg`, `typ` and `kid`
|
|
88
|
+
* are emitted in that order with no whitespace, which is the byte sequence the
|
|
89
|
+
* server's golden vector pins.
|
|
90
|
+
*/
|
|
91
|
+
export declare function modeCProtectedHeader(requestGrantId: string): string;
|
|
92
|
+
/**
|
|
93
|
+
* ModeCServerClock carries the authenticated server time of source §2.5. It is
|
|
94
|
+
* seeded from the grant result's `server_time` and refreshed only from a
|
|
95
|
+
* current authenticated `/v1/me` response; it installs no local clock override
|
|
96
|
+
* and no tolerance.
|
|
97
|
+
*/
|
|
98
|
+
export declare class ModeCServerClock {
|
|
99
|
+
#private;
|
|
100
|
+
constructor(serverTimeSeconds: number, localNowMs: number);
|
|
101
|
+
/** adopt accepts one authenticated integer NumericDate reading. */
|
|
102
|
+
adopt(serverTimeSeconds: number, localNowMs: number): void;
|
|
103
|
+
/** nowSeconds projects the adopted server reading onto the current local instant. */
|
|
104
|
+
nowSeconds(localNowMs: number): number;
|
|
105
|
+
}
|
|
106
|
+
/** ModeCSigningRequest is the closed input of `signMediatedRequest`. */
|
|
107
|
+
export interface ModeCSigningRequest {
|
|
108
|
+
readonly grant: PairedRequestGrant;
|
|
109
|
+
readonly prepared: PreparedMediatedTransfer;
|
|
110
|
+
readonly crypto: Crypto;
|
|
111
|
+
readonly serverNowSeconds: number;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* signMediatedRequest produces the compact ES256 proof over the exact
|
|
115
|
+
* displayed intent.
|
|
116
|
+
*
|
|
117
|
+
* The order is fixed so that every refusal happens before the signing key is
|
|
118
|
+
* touched: the closed input is checked, the displayed intent is independently
|
|
119
|
+
* rederived from the grant and compared, the grant window is checked, and only
|
|
120
|
+
* then are the digest, the 256-bit jti and the signature produced. A caller
|
|
121
|
+
* that hands this function a string, a blob or any backend-supplied payload is
|
|
122
|
+
* refused with `opaque_signing_input_refused` and no crypto call occurs.
|
|
123
|
+
*/
|
|
124
|
+
export declare function signMediatedRequest(request: ModeCSigningRequest): Promise<SignedMediatedRequest>;
|
|
125
|
+
/**
|
|
126
|
+
* assertDisplayedIntent is the display-to-intent consistency check of source
|
|
127
|
+
* §1.4. The operation is rederived from the grant binding and the displayed
|
|
128
|
+
* recipient, amount and fee, so a display field changed after preparation and
|
|
129
|
+
* an operation field changed after display are both refused before signing.
|
|
130
|
+
*/
|
|
131
|
+
export declare function assertDisplayedIntent(grant: PairedRequestGrant, prepared: PreparedMediatedTransfer): void;
|
package/dist/modeC.js
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Mode C typed transfer intent and compact ES256 request proof (source §1.4,
|
|
2
|
+
// §2.2, §2.5).
|
|
3
|
+
//
|
|
4
|
+
// This module owns exactly two client responsibilities: display the typed
|
|
5
|
+
// native wallet.send intent, and sign the canonical bytes of that displayed
|
|
6
|
+
// intent with the enrolled non-extractable key. It performs no HTTP and it
|
|
7
|
+
// exports no forward, approve, refuse or backend-signing helper: forwarding and
|
|
8
|
+
// refusal belong to the customer backend, and operation status and result reads
|
|
9
|
+
// use the generic helpers in `client.ts` (source §1.4, AUTH-009-A05).
|
|
10
|
+
//
|
|
11
|
+
// Every locally detectable fault is refused before any crypto call, so an
|
|
12
|
+
// arbitrary or backend-supplied blob never reaches the signing key.
|
|
13
|
+
import { ClientFlowError, base64url, canonicalDecimal, integer } from "./auth.js";
|
|
14
|
+
import { canonicalJSON, canonicalJSONBytes, sha256HexLower } from "./canonicalize.js";
|
|
15
|
+
import {} from "./requestGrant.js";
|
|
16
|
+
export { canonicalJSON, canonicalJSONBytes, sha256HexLower, MaxCanonicalDepth, } from "./canonicalize.js";
|
|
17
|
+
export { MaxRequestGrantLifetimeSeconds, MaxRequestGrantRecipients, RequestGrantStore, assertGrantMatches, buildRequestGrantEnrollment, enrollRequestGrant, generateRequestSigningKey, jwkThumbprint, parseRequestGrantResult, parseRequestGrantResultEnvelope, } from "./requestGrant.js";
|
|
18
|
+
/** ModeCProofVersion, Audience, Type and Kind are the closed proof constants of source §2.5. */
|
|
19
|
+
export const ModeCProofVersion = "1.0";
|
|
20
|
+
export const ModeCProofAudience = "authorization-api:mode-c";
|
|
21
|
+
export const ModeCProofType = "auth-user-request+jws";
|
|
22
|
+
export const ModeCProofKind = "wallet.send";
|
|
23
|
+
/** ModeCProofPayloadMembers is the exact member count of the signed payload. */
|
|
24
|
+
export const ModeCProofPayloadMembers = 17;
|
|
25
|
+
/** MaxModeCProofLifetimeSeconds bounds exp-iat. No grace extends it. */
|
|
26
|
+
export const MaxModeCProofLifetimeSeconds = 60;
|
|
27
|
+
/** ModeCJtiBytes is the 256-bit jti entropy that is also the Idempotency-Key. */
|
|
28
|
+
export const ModeCJtiBytes = 32;
|
|
29
|
+
const maxUint256 = (1n << 256n) - 1n;
|
|
30
|
+
const printableRecipient = /^[\x21-\x7e]{1,256}$/;
|
|
31
|
+
/**
|
|
32
|
+
* prepareMediatedTransfer derives the displayed intent and the exact closed
|
|
33
|
+
* OperationCreate from the paired grant. It refuses locally before any request
|
|
34
|
+
* or signature: a recipient outside the frozen allowlist, a noncanonical or
|
|
35
|
+
* over-ceiling quantity and a zero amount all stop here.
|
|
36
|
+
*/
|
|
37
|
+
export function prepareMediatedTransfer(paired, intent) {
|
|
38
|
+
const declared = closed(intent, ["recipient", "amountRaw", "maxFeeRaw"]);
|
|
39
|
+
const recipient = declared.recipient;
|
|
40
|
+
const amountRaw = declared.amountRaw;
|
|
41
|
+
const maxFeeRaw = declared.maxFeeRaw;
|
|
42
|
+
if (typeof recipient !== "string" || !printableRecipient.test(recipient) || !paired.grant.bounds.recipientAllowlist.includes(recipient)) {
|
|
43
|
+
throw new ClientFlowError("invalid_transfer_intent");
|
|
44
|
+
}
|
|
45
|
+
if (!rawQuantity(amountRaw, true) || !rawQuantity(maxFeeRaw, false) ||
|
|
46
|
+
BigInt(amountRaw) > BigInt(paired.grant.bounds.maxAmountRaw) || BigInt(maxFeeRaw) > BigInt(paired.grant.bounds.maxFeeRaw)) {
|
|
47
|
+
throw new ClientFlowError("invalid_transfer_intent");
|
|
48
|
+
}
|
|
49
|
+
const binding = paired.grant.binding;
|
|
50
|
+
const network = paired.grant.bounds.network;
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
display: Object.freeze({
|
|
53
|
+
kind: "wallet.send", ownerId: binding.accountId, walletId: binding.walletId, network,
|
|
54
|
+
asset: "native", recipient, amountRaw, maxFeeRaw,
|
|
55
|
+
}),
|
|
56
|
+
operation: Object.freeze({
|
|
57
|
+
kind: "wallet.send", owner_type: "account", owner_id: binding.accountId, wallet_id: binding.walletId,
|
|
58
|
+
payload: Object.freeze({ network, asset: "native", recipient, amount_raw: amountRaw, max_fee_raw: maxFeeRaw }),
|
|
59
|
+
}),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* canonicalMediatedRequest is the RFC 8785 serialization the request hash
|
|
64
|
+
* covers. It is produced from the SDK's own closed body, never from transport
|
|
65
|
+
* bytes.
|
|
66
|
+
*/
|
|
67
|
+
export function canonicalMediatedRequest(operation) {
|
|
68
|
+
const closedOperation = closed(operation, ["kind", "owner_type", "owner_id", "wallet_id", "payload"]);
|
|
69
|
+
const payload = closed(closedOperation.payload, ["network", "asset", "recipient", "amount_raw", "max_fee_raw"]);
|
|
70
|
+
if (closedOperation.kind !== ModeCProofKind || closedOperation.owner_type !== "account" || payload.asset !== "native") {
|
|
71
|
+
throw new ClientFlowError("invalid_transfer_intent");
|
|
72
|
+
}
|
|
73
|
+
return canonicalJSON({
|
|
74
|
+
kind: closedOperation.kind,
|
|
75
|
+
owner_type: closedOperation.owner_type,
|
|
76
|
+
owner_id: closedOperation.owner_id,
|
|
77
|
+
wallet_id: closedOperation.wallet_id,
|
|
78
|
+
payload: {
|
|
79
|
+
network: payload.network,
|
|
80
|
+
asset: payload.asset,
|
|
81
|
+
recipient: payload.recipient,
|
|
82
|
+
amount_raw: payload.amount_raw,
|
|
83
|
+
max_fee_raw: payload.max_fee_raw,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/** mediatedRequestHash is the lowercase hex SHA-256 of the canonical request bytes. */
|
|
88
|
+
export function mediatedRequestHash(crypto, operation) {
|
|
89
|
+
return sha256HexLower(crypto, new TextEncoder().encode(canonicalMediatedRequest(operation)));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* modeCProtectedHeader is the closed protected header of source §2.5 in its
|
|
93
|
+
* exact member order. The header is not canonicalized: `alg`, `typ` and `kid`
|
|
94
|
+
* are emitted in that order with no whitespace, which is the byte sequence the
|
|
95
|
+
* server's golden vector pins.
|
|
96
|
+
*/
|
|
97
|
+
export function modeCProtectedHeader(requestGrantId) {
|
|
98
|
+
return `{"alg":"ES256","typ":${JSON.stringify(ModeCProofType)},"kid":${JSON.stringify(requestGrantId)}}`;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* ModeCServerClock carries the authenticated server time of source §2.5. It is
|
|
102
|
+
* seeded from the grant result's `server_time` and refreshed only from a
|
|
103
|
+
* current authenticated `/v1/me` response; it installs no local clock override
|
|
104
|
+
* and no tolerance.
|
|
105
|
+
*/
|
|
106
|
+
export class ModeCServerClock {
|
|
107
|
+
#serverTime;
|
|
108
|
+
#observedMs;
|
|
109
|
+
constructor(serverTimeSeconds, localNowMs) {
|
|
110
|
+
this.#serverTime = 0;
|
|
111
|
+
this.#observedMs = 0;
|
|
112
|
+
this.adopt(serverTimeSeconds, localNowMs);
|
|
113
|
+
}
|
|
114
|
+
/** adopt accepts one authenticated integer NumericDate reading. */
|
|
115
|
+
adopt(serverTimeSeconds, localNowMs) {
|
|
116
|
+
if (!integer(serverTimeSeconds) || serverTimeSeconds <= 0 || !Number.isFinite(localNowMs))
|
|
117
|
+
throw new ClientFlowError("invalid_server_time");
|
|
118
|
+
this.#serverTime = serverTimeSeconds;
|
|
119
|
+
this.#observedMs = localNowMs;
|
|
120
|
+
}
|
|
121
|
+
/** nowSeconds projects the adopted server reading onto the current local instant. */
|
|
122
|
+
nowSeconds(localNowMs) {
|
|
123
|
+
if (!Number.isFinite(localNowMs))
|
|
124
|
+
throw new ClientFlowError("invalid_server_time");
|
|
125
|
+
const elapsed = Math.floor((localNowMs - this.#observedMs) / 1_000);
|
|
126
|
+
const now = this.#serverTime + elapsed;
|
|
127
|
+
if (!integer(now) || now <= 0)
|
|
128
|
+
throw new ClientFlowError("invalid_server_time");
|
|
129
|
+
return now;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* signMediatedRequest produces the compact ES256 proof over the exact
|
|
134
|
+
* displayed intent.
|
|
135
|
+
*
|
|
136
|
+
* The order is fixed so that every refusal happens before the signing key is
|
|
137
|
+
* touched: the closed input is checked, the displayed intent is independently
|
|
138
|
+
* rederived from the grant and compared, the grant window is checked, and only
|
|
139
|
+
* then are the digest, the 256-bit jti and the signature produced. A caller
|
|
140
|
+
* that hands this function a string, a blob or any backend-supplied payload is
|
|
141
|
+
* refused with `opaque_signing_input_refused` and no crypto call occurs.
|
|
142
|
+
*/
|
|
143
|
+
export async function signMediatedRequest(request) {
|
|
144
|
+
const input = closed(request, ["grant", "prepared", "crypto", "serverNowSeconds"]);
|
|
145
|
+
const grant = input.grant;
|
|
146
|
+
const prepared = input.prepared;
|
|
147
|
+
const crypto = input.crypto;
|
|
148
|
+
const serverNowSeconds = input.serverNowSeconds;
|
|
149
|
+
if (!grant?.grant || !grant.key?.privateKey || !prepared?.display || !prepared.operation)
|
|
150
|
+
throw new ClientFlowError("opaque_signing_input_refused");
|
|
151
|
+
if (!crypto?.subtle || typeof crypto.subtle.sign !== "function" || typeof crypto.subtle.digest !== "function" || typeof crypto.getRandomValues !== "function") {
|
|
152
|
+
throw new ClientFlowError("runtime_capability_unavailable");
|
|
153
|
+
}
|
|
154
|
+
assertDisplayedIntent(grant, prepared);
|
|
155
|
+
if (!integer(serverNowSeconds) || serverNowSeconds <= 0 || serverNowSeconds >= grant.grant.expiresAt) {
|
|
156
|
+
throw new ClientFlowError("request_grant_reenrollment_required");
|
|
157
|
+
}
|
|
158
|
+
const issuedAt = serverNowSeconds;
|
|
159
|
+
const expiresAt = Math.min(issuedAt + MaxModeCProofLifetimeSeconds, grant.grant.expiresAt);
|
|
160
|
+
if (expiresAt <= issuedAt || expiresAt - issuedAt > MaxModeCProofLifetimeSeconds)
|
|
161
|
+
throw new ClientFlowError("request_grant_reenrollment_required");
|
|
162
|
+
const requestHash = await mediatedRequestHash(crypto, prepared.operation);
|
|
163
|
+
const jti = base64url(crypto.getRandomValues(new Uint8Array(ModeCJtiBytes)));
|
|
164
|
+
const binding = grant.grant.binding;
|
|
165
|
+
const payload = canonicalJSONBytes({
|
|
166
|
+
version: ModeCProofVersion,
|
|
167
|
+
aud: ModeCProofAudience,
|
|
168
|
+
tenant_id: binding.tenantId,
|
|
169
|
+
application_id: binding.applicationId,
|
|
170
|
+
environment_id: binding.environmentId,
|
|
171
|
+
client_id: binding.clientId,
|
|
172
|
+
account_id: binding.accountId,
|
|
173
|
+
session_id: binding.sessionId,
|
|
174
|
+
service_principal_id: binding.servicePrincipalId,
|
|
175
|
+
wallet_id: binding.walletId,
|
|
176
|
+
wallet_generation: binding.walletGeneration,
|
|
177
|
+
mapping_version: binding.mappingVersion,
|
|
178
|
+
kind: ModeCProofKind,
|
|
179
|
+
request_hash: requestHash,
|
|
180
|
+
jti,
|
|
181
|
+
iat: issuedAt,
|
|
182
|
+
exp: expiresAt,
|
|
183
|
+
});
|
|
184
|
+
const header = new TextEncoder().encode(modeCProtectedHeader(grant.grant.requestGrantId));
|
|
185
|
+
const signingInput = `${base64url(header)}.${base64url(payload)}`;
|
|
186
|
+
const signature = new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: { name: "SHA-256" } }, grant.key.privateKey, new TextEncoder().encode(signingInput)));
|
|
187
|
+
// WebCrypto ECDSA already emits the 64-byte R||S encoding JWS requires; a
|
|
188
|
+
// runtime that returns DER or another length is refused, never reshaped.
|
|
189
|
+
if (signature.length !== 64)
|
|
190
|
+
throw new ClientFlowError("invalid_request_signature");
|
|
191
|
+
return Object.freeze({
|
|
192
|
+
display: prepared.display,
|
|
193
|
+
operation: prepared.operation,
|
|
194
|
+
userProof: `${signingInput}.${base64url(signature)}`,
|
|
195
|
+
idempotencyKey: jti,
|
|
196
|
+
jti,
|
|
197
|
+
requestHash,
|
|
198
|
+
issuedAt,
|
|
199
|
+
expiresAt,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* assertDisplayedIntent is the display-to-intent consistency check of source
|
|
204
|
+
* §1.4. The operation is rederived from the grant binding and the displayed
|
|
205
|
+
* recipient, amount and fee, so a display field changed after preparation and
|
|
206
|
+
* an operation field changed after display are both refused before signing.
|
|
207
|
+
*/
|
|
208
|
+
export function assertDisplayedIntent(grant, prepared) {
|
|
209
|
+
const display = closed(prepared.display, ["kind", "ownerId", "walletId", "network", "asset", "recipient", "amountRaw", "maxFeeRaw"]);
|
|
210
|
+
let rederived;
|
|
211
|
+
try {
|
|
212
|
+
rederived = prepareMediatedTransfer(grant, {
|
|
213
|
+
recipient: display.recipient,
|
|
214
|
+
amountRaw: display.amountRaw,
|
|
215
|
+
maxFeeRaw: display.maxFeeRaw,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new ClientFlowError("display_intent_mismatch");
|
|
220
|
+
}
|
|
221
|
+
const sameDisplay = ["kind", "ownerId", "walletId", "network", "asset", "recipient", "amountRaw", "maxFeeRaw"]
|
|
222
|
+
.every((member) => display[member] === rederived.display[member]);
|
|
223
|
+
if (!sameDisplay || canonicalMediatedRequest(prepared.operation) !== canonicalMediatedRequest(rederived.operation)) {
|
|
224
|
+
throw new ClientFlowError("display_intent_mismatch");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* closed accepts exactly the named members of a plain object. A string, a
|
|
229
|
+
* binary blob, an array and any extra or missing member are refused, which is
|
|
230
|
+
* how arbitrary backend-supplied bytes are rejected before crypto.
|
|
231
|
+
*/
|
|
232
|
+
function closed(value, required) {
|
|
233
|
+
if (value === null || typeof value !== "object" || Array.isArray(value) || ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
|
|
234
|
+
throw new ClientFlowError("opaque_signing_input_refused");
|
|
235
|
+
}
|
|
236
|
+
const result = value;
|
|
237
|
+
const allowed = new Set(required);
|
|
238
|
+
if (required.some((member) => !(member in result)) || Object.keys(result).some((member) => !allowed.has(member))) {
|
|
239
|
+
throw new ClientFlowError("opaque_signing_input_refused");
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
function rawQuantity(value, positive) {
|
|
244
|
+
if (!canonicalDecimal(value))
|
|
245
|
+
return false;
|
|
246
|
+
const quantity = BigInt(value);
|
|
247
|
+
return quantity <= maxUint256 && (!positive || quantity > 0n);
|
|
248
|
+
}
|