@skrr-ai/auth-core 0.1.2
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/README.md +21 -0
- package/dist/cjs/aead.d.ts +24 -0
- package/dist/cjs/aead.js +65 -0
- package/dist/cjs/authExpiredDetector.d.ts +65 -0
- package/dist/cjs/authExpiredDetector.js +89 -0
- package/dist/cjs/authHelper.d.ts +51 -0
- package/dist/cjs/authHelper.js +321 -0
- package/dist/cjs/credentialEnvelope.d.ts +85 -0
- package/dist/cjs/credentialEnvelope.js +230 -0
- package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/cjs/credentialEnvelopeBridge.js +637 -0
- package/dist/cjs/credentialResolver.d.ts +278 -0
- package/dist/cjs/credentialResolver.js +423 -0
- package/dist/cjs/daemonPair.d.ts +172 -0
- package/dist/cjs/daemonPair.js +495 -0
- package/dist/cjs/daemonScopes.d.ts +74 -0
- package/dist/cjs/daemonScopes.js +84 -0
- package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
- package/dist/cjs/deviceIdentityBridge.js +591 -0
- package/dist/cjs/deviceKey.d.ts +167 -0
- package/dist/cjs/deviceKey.js +332 -0
- package/dist/cjs/fdToken.d.ts +17 -0
- package/dist/cjs/fdToken.js +238 -0
- package/dist/cjs/harnessTrust.d.ts +72 -0
- package/dist/cjs/harnessTrust.js +103 -0
- package/dist/cjs/headers-helper.d.ts +25 -0
- package/dist/cjs/headers-helper.js +323 -0
- package/dist/cjs/index.d.ts +37 -0
- package/dist/cjs/index.js +305 -0
- package/dist/cjs/jwtUtils.d.ts +35 -0
- package/dist/cjs/jwtUtils.js +61 -0
- package/dist/cjs/kek/index.d.ts +89 -0
- package/dist/cjs/kek/index.js +208 -0
- package/dist/cjs/kek/linux.d.ts +92 -0
- package/dist/cjs/kek/linux.js +596 -0
- package/dist/cjs/kek/macos.d.ts +112 -0
- package/dist/cjs/kek/macos.js +599 -0
- package/dist/cjs/kek/types.d.ts +111 -0
- package/dist/cjs/kek/types.js +43 -0
- package/dist/cjs/kek/windows.d.ts +71 -0
- package/dist/cjs/kek/windows.js +350 -0
- package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
- package/dist/cjs/kek/zeroize-registry.js +63 -0
- package/dist/cjs/legacyStatePreflight.d.ts +67 -0
- package/dist/cjs/legacyStatePreflight.js +78 -0
- package/dist/cjs/localIdentity.d.ts +54 -0
- package/dist/cjs/localIdentity.js +57 -0
- package/dist/cjs/loginLocalhost.d.ts +115 -0
- package/dist/cjs/loginLocalhost.js +368 -0
- package/dist/cjs/loginWithLocalhost.d.ts +131 -0
- package/dist/cjs/loginWithLocalhost.js +359 -0
- package/dist/cjs/machineId.d.ts +35 -0
- package/dist/cjs/machineId.js +239 -0
- package/dist/cjs/messages.d.ts +9 -0
- package/dist/cjs/messages.js +44 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pkce.d.ts +62 -0
- package/dist/cjs/pkce.js +158 -0
- package/dist/cjs/recoveryCode.d.ts +61 -0
- package/dist/cjs/recoveryCode.js +213 -0
- package/dist/cjs/refresh.d.ts +215 -0
- package/dist/cjs/refresh.js +877 -0
- package/dist/cjs/refreshClassification.d.ts +53 -0
- package/dist/cjs/refreshClassification.js +105 -0
- package/dist/cjs/refreshScheduler.d.ts +53 -0
- package/dist/cjs/refreshScheduler.js +332 -0
- package/dist/cjs/runtime.d.ts +149 -0
- package/dist/cjs/runtime.js +136 -0
- package/dist/cjs/spawnEnv.d.ts +76 -0
- package/dist/cjs/spawnEnv.js +153 -0
- package/dist/cjs/ttlParser.d.ts +54 -0
- package/dist/cjs/ttlParser.js +87 -0
- package/dist/cjs/types.d.ts +91 -0
- package/dist/cjs/types.js +26 -0
- package/dist/esm/aead.d.ts +24 -0
- package/dist/esm/aead.js +57 -0
- package/dist/esm/authExpiredDetector.d.ts +65 -0
- package/dist/esm/authExpiredDetector.js +85 -0
- package/dist/esm/authHelper.d.ts +51 -0
- package/dist/esm/authHelper.js +311 -0
- package/dist/esm/credentialEnvelope.d.ts +85 -0
- package/dist/esm/credentialEnvelope.js +213 -0
- package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/esm/credentialEnvelopeBridge.js +620 -0
- package/dist/esm/credentialResolver.d.ts +278 -0
- package/dist/esm/credentialResolver.js +414 -0
- package/dist/esm/daemonPair.d.ts +172 -0
- package/dist/esm/daemonPair.js +476 -0
- package/dist/esm/daemonScopes.d.ts +74 -0
- package/dist/esm/daemonScopes.js +80 -0
- package/dist/esm/deviceIdentityBridge.d.ts +113 -0
- package/dist/esm/deviceIdentityBridge.js +572 -0
- package/dist/esm/deviceKey.d.ts +167 -0
- package/dist/esm/deviceKey.js +320 -0
- package/dist/esm/fdToken.d.ts +17 -0
- package/dist/esm/fdToken.js +231 -0
- package/dist/esm/harnessTrust.d.ts +72 -0
- package/dist/esm/harnessTrust.js +97 -0
- package/dist/esm/headers-helper.d.ts +25 -0
- package/dist/esm/headers-helper.js +316 -0
- package/dist/esm/index.d.ts +37 -0
- package/dist/esm/index.js +144 -0
- package/dist/esm/jwtUtils.d.ts +35 -0
- package/dist/esm/jwtUtils.js +57 -0
- package/dist/esm/kek/index.d.ts +89 -0
- package/dist/esm/kek/index.js +162 -0
- package/dist/esm/kek/linux.d.ts +92 -0
- package/dist/esm/kek/linux.js +585 -0
- package/dist/esm/kek/macos.d.ts +112 -0
- package/dist/esm/kek/macos.js +586 -0
- package/dist/esm/kek/types.d.ts +111 -0
- package/dist/esm/kek/types.js +39 -0
- package/dist/esm/kek/windows.d.ts +71 -0
- package/dist/esm/kek/windows.js +344 -0
- package/dist/esm/kek/zeroize-registry.d.ts +44 -0
- package/dist/esm/kek/zeroize-registry.js +58 -0
- package/dist/esm/legacyStatePreflight.d.ts +67 -0
- package/dist/esm/legacyStatePreflight.js +71 -0
- package/dist/esm/localIdentity.d.ts +54 -0
- package/dist/esm/localIdentity.js +54 -0
- package/dist/esm/loginLocalhost.d.ts +115 -0
- package/dist/esm/loginLocalhost.js +355 -0
- package/dist/esm/loginWithLocalhost.d.ts +131 -0
- package/dist/esm/loginWithLocalhost.js +353 -0
- package/dist/esm/machineId.d.ts +35 -0
- package/dist/esm/machineId.js +231 -0
- package/dist/esm/messages.d.ts +9 -0
- package/dist/esm/messages.js +40 -0
- package/dist/esm/pkce.d.ts +62 -0
- package/dist/esm/pkce.js +148 -0
- package/dist/esm/recoveryCode.d.ts +61 -0
- package/dist/esm/recoveryCode.js +207 -0
- package/dist/esm/refresh.d.ts +215 -0
- package/dist/esm/refresh.js +863 -0
- package/dist/esm/refreshClassification.d.ts +53 -0
- package/dist/esm/refreshClassification.js +106 -0
- package/dist/esm/refreshScheduler.d.ts +53 -0
- package/dist/esm/refreshScheduler.js +329 -0
- package/dist/esm/runtime.d.ts +149 -0
- package/dist/esm/runtime.js +121 -0
- package/dist/esm/spawnEnv.d.ts +76 -0
- package/dist/esm/spawnEnv.js +149 -0
- package/dist/esm/ttlParser.d.ts +54 -0
- package/dist/esm/ttlParser.js +83 -0
- package/dist/esm/types.d.ts +91 -0
- package/dist/esm/types.js +21 -0
- package/package.json +82 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** Box nonce length per RFC 8439 / NaCl. */
|
|
2
|
+
export declare const PAIR_NONCE_LEN = 24;
|
|
3
|
+
/** Curve25519 public key length. */
|
|
4
|
+
export declare const PAIR_PUBKEY_LEN = 32;
|
|
5
|
+
/** Curve25519 secret key length. */
|
|
6
|
+
export declare const PAIR_SECRET_LEN = 32;
|
|
7
|
+
/** Big-endian uint16 length prefix for the AAD trailer. */
|
|
8
|
+
export declare const PAIR_AAD_LEN_PREFIX = 2;
|
|
9
|
+
/** Server validator caps `encryptedPayload` at 8192 chars (base64). */
|
|
10
|
+
export declare const PAIR_BUNDLE_MAX_BASE64_LEN = 8192;
|
|
11
|
+
/** Conservative ceiling on the trailer to keep the bundle well under cap. */
|
|
12
|
+
export declare const PAIR_AAD_MAX_LEN = 1024;
|
|
13
|
+
/** AAD prefix — version-bumped on any breaking format change. */
|
|
14
|
+
export declare const PAIR_AAD_PREFIX = "oversky:pair:v1";
|
|
15
|
+
export type PairBundleErrorCode = 'BUNDLE_TOO_SHORT' | 'BUNDLE_TOO_LONG' | 'BUNDLE_BAD_BASE64' | 'AAD_LEN_OVERFLOW' | 'AAD_LEN_UNDERFLOW' | 'AAD_MISMATCH' | 'DECRYPT_FAILED' | 'PLAINTEXT_NOT_JSON' | 'PLAINTEXT_BAD_SHAPE' | 'DAEMON_ID_MISMATCH' | 'INVALID_KEY';
|
|
16
|
+
export declare class PairBundleError extends Error {
|
|
17
|
+
readonly code: PairBundleErrorCode;
|
|
18
|
+
constructor(code: PairBundleErrorCode, message: string);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Plaintext shape sealed inside the box. Versioned via `v` so future
|
|
22
|
+
* additions (e.g. data-encryption-key for L12 envelope handoff) extend
|
|
23
|
+
* the schema without breaking older daemons.
|
|
24
|
+
*
|
|
25
|
+
* `token` is optional because Stage-E #1 step 4 daemon-scope mints ship
|
|
26
|
+
* `opaqueAccessToken` only (no JWT). At least one of `token` or
|
|
27
|
+
* `opaqueAccessToken` MUST be present, enforced in `parsePairPlaintext`.
|
|
28
|
+
*/
|
|
29
|
+
export interface PairPlaintextV1 {
|
|
30
|
+
v: 1;
|
|
31
|
+
/** Daemon-scope JWT — optional after Stage-E #1 step 4. */
|
|
32
|
+
token?: string;
|
|
33
|
+
/** Access token expiry (epoch ms). */
|
|
34
|
+
expiresAt?: number;
|
|
35
|
+
/** Rotating refresh token. */
|
|
36
|
+
refreshToken?: string;
|
|
37
|
+
/** Refresh token expiry (epoch ms). */
|
|
38
|
+
refreshExpiresAt?: number;
|
|
39
|
+
/** Stage-E #1 — opaque `osk_dmn_*` daemon access token. */
|
|
40
|
+
opaqueAccessToken?: string;
|
|
41
|
+
/**
|
|
42
|
+
* The daemonId the credentials were minted for. Daemon MUST verify
|
|
43
|
+
* this matches the `daemonId` it advertised in the QR — guards against
|
|
44
|
+
* a misdirected /respond targeting a different daemon's pairing row.
|
|
45
|
+
*/
|
|
46
|
+
daemonId: string;
|
|
47
|
+
/** Mobile-side issuance time (epoch ms). For audit/log only. */
|
|
48
|
+
issuedAt: number;
|
|
49
|
+
}
|
|
50
|
+
export interface PairBundle {
|
|
51
|
+
/** Raw 24-byte nonce. */
|
|
52
|
+
nonce: Uint8Array;
|
|
53
|
+
/** Raw 32-byte mobile ephemeral public key. */
|
|
54
|
+
ephemeralPublicKey: Uint8Array;
|
|
55
|
+
/** Box ciphertext (plaintext + Poly1305 tag). */
|
|
56
|
+
ciphertext: Uint8Array;
|
|
57
|
+
/** Canonical AAD bytes — SHOULD match the rebuilt AAD on verify. */
|
|
58
|
+
aad: Uint8Array;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Build the canonical AAD bytes for a pairing response. Both sides
|
|
62
|
+
* (mobile-builder and daemon-verifier) call this with identical inputs:
|
|
63
|
+
* - mobile gets `responseUserId` from its own JWT (`req.user.id`)
|
|
64
|
+
* - daemon gets `responseUserId` from the `/poll` response
|
|
65
|
+
*
|
|
66
|
+
* The encoding is ASCII; non-ASCII inputs are rejected so a hostile
|
|
67
|
+
* daemonId/userId can't smuggle bytes that would shift the boundary.
|
|
68
|
+
*/
|
|
69
|
+
export declare function buildPairAad(daemonId: string, responseUserId: string): Uint8Array;
|
|
70
|
+
/**
|
|
71
|
+
* Parse and validate the JSON plaintext that came out of the box. We
|
|
72
|
+
* keep this tolerant on output (older daemons may write extra fields)
|
|
73
|
+
* but strict on the invariant fields the daemon needs to persist.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parsePairPlaintext(raw: unknown): PairPlaintextV1;
|
|
76
|
+
export interface AssembleBundleOptions {
|
|
77
|
+
/** Plaintext to seal — JSON-stringified before boxing. */
|
|
78
|
+
plaintext: PairPlaintextV1;
|
|
79
|
+
/** Daemon's ephemeral X25519 public key (32 bytes). */
|
|
80
|
+
daemonPublicKey: Uint8Array;
|
|
81
|
+
/** Mobile's ephemeral X25519 secret key (32 bytes). */
|
|
82
|
+
ephemeralSecretKey: Uint8Array;
|
|
83
|
+
/** Mobile's ephemeral X25519 public key (32 bytes), echoed into bundle. */
|
|
84
|
+
ephemeralPublicKey: Uint8Array;
|
|
85
|
+
/** Canonical AAD bytes from `buildPairAad`. */
|
|
86
|
+
aad: Uint8Array;
|
|
87
|
+
/**
|
|
88
|
+
* Override the random nonce — for tests only. Production callers must
|
|
89
|
+
* leave this undefined so a fresh nonce is generated.
|
|
90
|
+
*/
|
|
91
|
+
__nonceForTest?: Uint8Array;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Mobile-side: encrypt the plaintext to the daemon's pubkey and frame
|
|
95
|
+
* the result for transport. Returns base64-encoded bundle suitable for
|
|
96
|
+
* the `encryptedPayload` field of `POST /api/auth/pair/respond`.
|
|
97
|
+
*/
|
|
98
|
+
export declare function assemblePairBundleBase64(opts: AssembleBundleOptions): string;
|
|
99
|
+
/**
|
|
100
|
+
* Parse the wire bytes into a `PairBundle` without decrypting. Used as
|
|
101
|
+
* a preflight by `openPairBundleBase64` and exposed for tests.
|
|
102
|
+
*/
|
|
103
|
+
export declare function parsePairBundle(bytes: Uint8Array): PairBundle;
|
|
104
|
+
export interface OpenBundleOptions {
|
|
105
|
+
/** Base64 wire payload from `/poll`'s `response` field. */
|
|
106
|
+
encryptedPayloadBase64: string;
|
|
107
|
+
/** Daemon's ephemeral X25519 secret key. */
|
|
108
|
+
daemonSecretKey: Uint8Array;
|
|
109
|
+
/**
|
|
110
|
+
* Canonical AAD bytes the daemon expects, rebuilt from `daemonId` +
|
|
111
|
+
* `responseUserId` returned by the /poll response.
|
|
112
|
+
*/
|
|
113
|
+
expectedAad: Uint8Array;
|
|
114
|
+
/**
|
|
115
|
+
* The daemonId the daemon advertised in its QR. We verify the
|
|
116
|
+
* decrypted plaintext echoes the SAME id, in addition to the AAD
|
|
117
|
+
* trailer the server bound. Belt-and-suspenders.
|
|
118
|
+
*/
|
|
119
|
+
expectedDaemonId: string;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Daemon-side: parse, AAD-verify, decrypt, JSON-parse, validate the
|
|
123
|
+
* pairing response. Returns the validated plaintext on success; throws
|
|
124
|
+
* `PairBundleError` on any failure.
|
|
125
|
+
*
|
|
126
|
+
* Constant-time-ish AAD comparison: tweetnacl's box.open already runs
|
|
127
|
+
* Poly1305 in constant time, and the AAD compare we do is byte-by-byte
|
|
128
|
+
* on a 1KB-bounded buffer — short side-channel exposure is negligible
|
|
129
|
+
* given we're comparing public-known canonical bytes.
|
|
130
|
+
*/
|
|
131
|
+
export declare function openPairBundleBase64(opts: OpenBundleOptions): PairPlaintextV1;
|
|
132
|
+
export interface PairUrlFields {
|
|
133
|
+
/** 32-byte daemon ephemeral public key, base64url no-pad. */
|
|
134
|
+
publicKeyBase64Url: string;
|
|
135
|
+
/** Server-issued pair id, hex. */
|
|
136
|
+
pairId: string;
|
|
137
|
+
/** Daemon id (free-form, ASCII, ≤128 chars). */
|
|
138
|
+
daemonId: string;
|
|
139
|
+
/**
|
|
140
|
+
* User-friendly machine label so the mobile can show "Approve pairing
|
|
141
|
+
* with MacBook-Pro?" — purely UX, not security-bearing.
|
|
142
|
+
*/
|
|
143
|
+
host?: string;
|
|
144
|
+
/** Server URL the daemon is paired against, for split-deployment hints. */
|
|
145
|
+
serverUrl?: string;
|
|
146
|
+
/**
|
|
147
|
+
* Wire-format protocol version. Bump on any breaking change to the
|
|
148
|
+
* bundle layout or AAD canonical form.
|
|
149
|
+
*/
|
|
150
|
+
v?: number;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Build the URL the daemon prints as a QR code. URL-safe parameters
|
|
154
|
+
* only; the bulk is the public key, which is fine.
|
|
155
|
+
*/
|
|
156
|
+
export declare function buildPairUrl(fields: PairUrlFields): string;
|
|
157
|
+
/**
|
|
158
|
+
* Parse a scanned URL back into structured fields. Returns null on any
|
|
159
|
+
* format error so the caller can show a user-friendly "not a pair QR"
|
|
160
|
+
* message rather than a stack trace. Strict validation of every field's
|
|
161
|
+
* shape keeps a hostile QR from steering the mobile at a bad daemonId.
|
|
162
|
+
*/
|
|
163
|
+
export declare function parsePairUrl(url: string): PairUrlFields | null;
|
|
164
|
+
export declare function uint8ArrayToBase64(bytes: Uint8Array): string;
|
|
165
|
+
export declare function base64ToUint8Array(b64: string): Uint8Array;
|
|
166
|
+
export declare function uint8ArrayToBase64Url(bytes: Uint8Array): string;
|
|
167
|
+
export declare function base64UrlToUint8Array(b64url: string): Uint8Array;
|
|
168
|
+
export interface PairKeyPair {
|
|
169
|
+
publicKey: Uint8Array;
|
|
170
|
+
secretKey: Uint8Array;
|
|
171
|
+
}
|
|
172
|
+
export declare function generatePairKeyPair(): PairKeyPair;
|
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* daemonPair.ts — pure-crypto primitives for the mobile-as-keyholder QR
|
|
3
|
+
* pairing handshake (Tier 1 #2 from Happy lessons).
|
|
4
|
+
*
|
|
5
|
+
* Wire format (matches `api/server/routes/daemonPair.js` header):
|
|
6
|
+
*
|
|
7
|
+
* bundle =
|
|
8
|
+
* nonce (24 bytes) // tweetnacl box nonce
|
|
9
|
+
* ephemeralPk (32 bytes) // mobile-side X25519 public key
|
|
10
|
+
* ciphertext (variable) // tweetnacl box(plaintext, daemonPk, ephSk, nonce)
|
|
11
|
+
* lenAad (2 bytes, big-endian)
|
|
12
|
+
* aad (lenAad bytes, ASCII)
|
|
13
|
+
*
|
|
14
|
+
* encryptedPayload = base64(bundle)
|
|
15
|
+
*
|
|
16
|
+
* Canonical AAD (C4 — bound by the server at /respond, rebuilt by the
|
|
17
|
+
* daemon at /poll for verify):
|
|
18
|
+
*
|
|
19
|
+
* oversky:pair:v1:daemonId=<daemonId>:responseUserId=<userId>
|
|
20
|
+
*
|
|
21
|
+
* Note: this module is platform-agnostic. It does no disk IO, no network
|
|
22
|
+
* IO, and no console IO. The daemon CLI orchestrator (`daemon/src/pair.ts`)
|
|
23
|
+
* imports these primitives; the mobile RN client mirrors the same shapes
|
|
24
|
+
* in TypeScript with its own tweetnacl.
|
|
25
|
+
*
|
|
26
|
+
* Lifetime invariant: the ephemeral X25519 secret key NEVER persists. The
|
|
27
|
+
* mobile generates one per pairing, uses it once, and discards. The daemon
|
|
28
|
+
* generates one per `oversky pair` invocation, uses it for the duration
|
|
29
|
+
* of the polling window, and discards on success/failure/abort.
|
|
30
|
+
*/
|
|
31
|
+
import nacl from 'tweetnacl';
|
|
32
|
+
// ---------------------------------------------------------------------
|
|
33
|
+
// Constants
|
|
34
|
+
// ---------------------------------------------------------------------
|
|
35
|
+
/** Box nonce length per RFC 8439 / NaCl. */
|
|
36
|
+
export const PAIR_NONCE_LEN = 24;
|
|
37
|
+
/** Curve25519 public key length. */
|
|
38
|
+
export const PAIR_PUBKEY_LEN = 32;
|
|
39
|
+
/** Curve25519 secret key length. */
|
|
40
|
+
export const PAIR_SECRET_LEN = 32;
|
|
41
|
+
/** Big-endian uint16 length prefix for the AAD trailer. */
|
|
42
|
+
export const PAIR_AAD_LEN_PREFIX = 2;
|
|
43
|
+
/** Server validator caps `encryptedPayload` at 8192 chars (base64). */
|
|
44
|
+
export const PAIR_BUNDLE_MAX_BASE64_LEN = 8192;
|
|
45
|
+
/** Conservative ceiling on the trailer to keep the bundle well under cap. */
|
|
46
|
+
export const PAIR_AAD_MAX_LEN = 1024;
|
|
47
|
+
/** AAD prefix — version-bumped on any breaking format change. */
|
|
48
|
+
export const PAIR_AAD_PREFIX = 'oversky:pair:v1';
|
|
49
|
+
export class PairBundleError extends Error {
|
|
50
|
+
code;
|
|
51
|
+
constructor(code, message) {
|
|
52
|
+
super(message);
|
|
53
|
+
this.name = 'PairBundleError';
|
|
54
|
+
this.code = code;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// ---------------------------------------------------------------------
|
|
58
|
+
// AAD
|
|
59
|
+
// ---------------------------------------------------------------------
|
|
60
|
+
/**
|
|
61
|
+
* Build the canonical AAD bytes for a pairing response. Both sides
|
|
62
|
+
* (mobile-builder and daemon-verifier) call this with identical inputs:
|
|
63
|
+
* - mobile gets `responseUserId` from its own JWT (`req.user.id`)
|
|
64
|
+
* - daemon gets `responseUserId` from the `/poll` response
|
|
65
|
+
*
|
|
66
|
+
* The encoding is ASCII; non-ASCII inputs are rejected so a hostile
|
|
67
|
+
* daemonId/userId can't smuggle bytes that would shift the boundary.
|
|
68
|
+
*/
|
|
69
|
+
export function buildPairAad(daemonId, responseUserId) {
|
|
70
|
+
if (typeof daemonId !== 'string' || daemonId.length === 0) {
|
|
71
|
+
throw new PairBundleError('AAD_MISMATCH', 'daemonId must be a non-empty string');
|
|
72
|
+
}
|
|
73
|
+
if (typeof responseUserId !== 'string' || responseUserId.length === 0) {
|
|
74
|
+
throw new PairBundleError('AAD_MISMATCH', 'responseUserId must be a non-empty string');
|
|
75
|
+
}
|
|
76
|
+
// Reject any non-ASCII printable byte. This forces the canonical
|
|
77
|
+
// encoding to be representable as 1-byte-per-char so length prefixes
|
|
78
|
+
// are unambiguous and a unicode-rebalancing attack on the trailer
|
|
79
|
+
// can't shift the AAD/ciphertext boundary.
|
|
80
|
+
if (!/^[\x21-\x7E]+$/.test(daemonId) || !/^[\x21-\x7E]+$/.test(responseUserId)) {
|
|
81
|
+
throw new PairBundleError('AAD_MISMATCH', 'daemonId and responseUserId must be ASCII printable (no spaces, no controls)');
|
|
82
|
+
}
|
|
83
|
+
const text = `${PAIR_AAD_PREFIX}:daemonId=${daemonId}:responseUserId=${responseUserId}`;
|
|
84
|
+
if (text.length > PAIR_AAD_MAX_LEN) {
|
|
85
|
+
throw new PairBundleError('AAD_LEN_OVERFLOW', 'AAD exceeds maximum length');
|
|
86
|
+
}
|
|
87
|
+
// Pure ASCII → safe to use TextEncoder (1 byte per char in this range).
|
|
88
|
+
return new TextEncoder().encode(text);
|
|
89
|
+
}
|
|
90
|
+
// ---------------------------------------------------------------------
|
|
91
|
+
// Plaintext shape validation
|
|
92
|
+
// ---------------------------------------------------------------------
|
|
93
|
+
/**
|
|
94
|
+
* Parse and validate the JSON plaintext that came out of the box. We
|
|
95
|
+
* keep this tolerant on output (older daemons may write extra fields)
|
|
96
|
+
* but strict on the invariant fields the daemon needs to persist.
|
|
97
|
+
*/
|
|
98
|
+
export function parsePairPlaintext(raw) {
|
|
99
|
+
if (!raw || typeof raw !== 'object') {
|
|
100
|
+
throw new PairBundleError('PLAINTEXT_BAD_SHAPE', 'plaintext is not an object');
|
|
101
|
+
}
|
|
102
|
+
const obj = raw;
|
|
103
|
+
if (obj.v !== 1) {
|
|
104
|
+
throw new PairBundleError('PLAINTEXT_BAD_SHAPE', `unsupported pair plaintext version: ${String(obj.v)}`);
|
|
105
|
+
}
|
|
106
|
+
const token = optString(obj.token);
|
|
107
|
+
const opaqueAccessToken = optString(obj.opaqueAccessToken);
|
|
108
|
+
if (!token && !opaqueAccessToken) {
|
|
109
|
+
throw new PairBundleError('PLAINTEXT_BAD_SHAPE', 'plaintext must include at least one of token or opaqueAccessToken');
|
|
110
|
+
}
|
|
111
|
+
const daemonId = optString(obj.daemonId);
|
|
112
|
+
if (!daemonId) {
|
|
113
|
+
throw new PairBundleError('PLAINTEXT_BAD_SHAPE', 'plaintext.daemonId is required');
|
|
114
|
+
}
|
|
115
|
+
const issuedAt = optNumber(obj.issuedAt);
|
|
116
|
+
if (issuedAt === undefined) {
|
|
117
|
+
throw new PairBundleError('PLAINTEXT_BAD_SHAPE', 'plaintext.issuedAt is required');
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
v: 1,
|
|
121
|
+
...(token ? { token } : {}),
|
|
122
|
+
...(opaqueAccessToken ? { opaqueAccessToken } : {}),
|
|
123
|
+
expiresAt: optNumber(obj.expiresAt),
|
|
124
|
+
refreshToken: optString(obj.refreshToken),
|
|
125
|
+
refreshExpiresAt: optNumber(obj.refreshExpiresAt),
|
|
126
|
+
daemonId,
|
|
127
|
+
issuedAt,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function optString(v) {
|
|
131
|
+
return typeof v === 'string' && v.length > 0 ? v : undefined;
|
|
132
|
+
}
|
|
133
|
+
function optNumber(v) {
|
|
134
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Mobile-side: encrypt the plaintext to the daemon's pubkey and frame
|
|
138
|
+
* the result for transport. Returns base64-encoded bundle suitable for
|
|
139
|
+
* the `encryptedPayload` field of `POST /api/auth/pair/respond`.
|
|
140
|
+
*/
|
|
141
|
+
export function assemblePairBundleBase64(opts) {
|
|
142
|
+
if (opts.daemonPublicKey.length !== PAIR_PUBKEY_LEN) {
|
|
143
|
+
throw new PairBundleError('INVALID_KEY', 'daemonPublicKey must be 32 bytes');
|
|
144
|
+
}
|
|
145
|
+
if (opts.ephemeralSecretKey.length !== PAIR_SECRET_LEN) {
|
|
146
|
+
throw new PairBundleError('INVALID_KEY', 'ephemeralSecretKey must be 32 bytes');
|
|
147
|
+
}
|
|
148
|
+
if (opts.ephemeralPublicKey.length !== PAIR_PUBKEY_LEN) {
|
|
149
|
+
throw new PairBundleError('INVALID_KEY', 'ephemeralPublicKey must be 32 bytes');
|
|
150
|
+
}
|
|
151
|
+
if (opts.aad.length === 0 || opts.aad.length > PAIR_AAD_MAX_LEN) {
|
|
152
|
+
throw new PairBundleError('AAD_LEN_OVERFLOW', 'aad must be 1..1024 bytes');
|
|
153
|
+
}
|
|
154
|
+
const nonce = opts.__nonceForTest ?? nacl.randomBytes(PAIR_NONCE_LEN);
|
|
155
|
+
if (nonce.length !== PAIR_NONCE_LEN) {
|
|
156
|
+
throw new PairBundleError('INVALID_KEY', 'nonce must be 24 bytes');
|
|
157
|
+
}
|
|
158
|
+
const plaintextJson = JSON.stringify(opts.plaintext);
|
|
159
|
+
const plaintextBytes = new TextEncoder().encode(plaintextJson);
|
|
160
|
+
const ciphertext = nacl.box(plaintextBytes, nonce, opts.daemonPublicKey, opts.ephemeralSecretKey);
|
|
161
|
+
const totalLen = PAIR_NONCE_LEN + PAIR_PUBKEY_LEN + ciphertext.length + PAIR_AAD_LEN_PREFIX + opts.aad.length;
|
|
162
|
+
const bundle = new Uint8Array(totalLen);
|
|
163
|
+
let offset = 0;
|
|
164
|
+
bundle.set(nonce, offset);
|
|
165
|
+
offset += PAIR_NONCE_LEN;
|
|
166
|
+
bundle.set(opts.ephemeralPublicKey, offset);
|
|
167
|
+
offset += PAIR_PUBKEY_LEN;
|
|
168
|
+
bundle.set(ciphertext, offset);
|
|
169
|
+
offset += ciphertext.length;
|
|
170
|
+
// Big-endian uint16 length prefix for AAD.
|
|
171
|
+
bundle[offset] = (opts.aad.length >>> 8) & 0xff;
|
|
172
|
+
bundle[offset + 1] = opts.aad.length & 0xff;
|
|
173
|
+
offset += PAIR_AAD_LEN_PREFIX;
|
|
174
|
+
bundle.set(opts.aad, offset);
|
|
175
|
+
const base64 = uint8ArrayToBase64(bundle);
|
|
176
|
+
if (base64.length > PAIR_BUNDLE_MAX_BASE64_LEN) {
|
|
177
|
+
throw new PairBundleError('BUNDLE_TOO_LONG', `encryptedPayload exceeds ${PAIR_BUNDLE_MAX_BASE64_LEN} chars`);
|
|
178
|
+
}
|
|
179
|
+
return base64;
|
|
180
|
+
}
|
|
181
|
+
// ---------------------------------------------------------------------
|
|
182
|
+
// Bundle parse + decrypt + AAD verify (daemon side)
|
|
183
|
+
// ---------------------------------------------------------------------
|
|
184
|
+
/**
|
|
185
|
+
* Parse the wire bytes into a `PairBundle` without decrypting. Used as
|
|
186
|
+
* a preflight by `openPairBundleBase64` and exposed for tests.
|
|
187
|
+
*/
|
|
188
|
+
export function parsePairBundle(bytes) {
|
|
189
|
+
const minLen = PAIR_NONCE_LEN + PAIR_PUBKEY_LEN + PAIR_AAD_LEN_PREFIX + 16;
|
|
190
|
+
if (bytes.length < minLen) {
|
|
191
|
+
throw new PairBundleError('BUNDLE_TOO_SHORT', `bundle is ${bytes.length} bytes, need at least ${minLen}`);
|
|
192
|
+
}
|
|
193
|
+
let offset = 0;
|
|
194
|
+
const nonce = bytes.slice(offset, offset + PAIR_NONCE_LEN);
|
|
195
|
+
offset += PAIR_NONCE_LEN;
|
|
196
|
+
const ephemeralPublicKey = bytes.slice(offset, offset + PAIR_PUBKEY_LEN);
|
|
197
|
+
offset += PAIR_PUBKEY_LEN;
|
|
198
|
+
// The AAD is at the END of the bundle (length-prefixed). Walk backwards
|
|
199
|
+
// from the tail so the remainder is unambiguously the ciphertext.
|
|
200
|
+
if (bytes.length - offset < PAIR_AAD_LEN_PREFIX + 1) {
|
|
201
|
+
throw new PairBundleError('BUNDLE_TOO_SHORT', 'no room for AAD trailer');
|
|
202
|
+
}
|
|
203
|
+
// Find the AAD-length prefix by walking forward through ciphertext +
|
|
204
|
+
// trailer. The ciphertext length is unknown until we know the AAD
|
|
205
|
+
// length. Simplest deterministic decode: assume the trailer is at the
|
|
206
|
+
// end and that lenAad fits the remaining bytes exactly.
|
|
207
|
+
//
|
|
208
|
+
// Specifically: the last (lenAad) bytes are AAD; the 2 bytes before
|
|
209
|
+
// that are the big-endian length; everything between offset and
|
|
210
|
+
// (bytes.length - lenAad - 2) is ciphertext.
|
|
211
|
+
if (bytes.length < offset + PAIR_AAD_LEN_PREFIX) {
|
|
212
|
+
throw new PairBundleError('BUNDLE_TOO_SHORT', 'no room for AAD length prefix');
|
|
213
|
+
}
|
|
214
|
+
// We need to walk: pick the lenAad as the last 2 bytes WITH the
|
|
215
|
+
// tail bytes after them — that means the prefix is at
|
|
216
|
+
// bytes.length - 2 - lenAad. But we don't know lenAad yet.
|
|
217
|
+
//
|
|
218
|
+
// The bundle layout puts the prefix BEFORE the AAD bytes, so the
|
|
219
|
+
// prefix is at `bytes.length - lenAad - 2`. The ONLY consistent
|
|
220
|
+
// decode is: lenAad = u16BE(bytes[bytes.length - 2 - L : ...]) where
|
|
221
|
+
// we walk L from 0 upward. The simpler and equally-correct approach:
|
|
222
|
+
// require the writer to ALSO put a trailer-length at the tail. But
|
|
223
|
+
// we don't — so we adopt the convention that lenAad lives
|
|
224
|
+
// immediately AFTER the ciphertext, which means we need the writer
|
|
225
|
+
// and reader to agree on where the ciphertext ends.
|
|
226
|
+
//
|
|
227
|
+
// The agreement: lenAad is encoded at offset
|
|
228
|
+
// bytes.length - lenAad - 2
|
|
229
|
+
// and the AAD itself is the last `lenAad` bytes. To find lenAad we
|
|
230
|
+
// first read the last 2 bytes? No — those are the tail of the AAD.
|
|
231
|
+
//
|
|
232
|
+
// Concrete decoder: walk lenAad candidates from the end. The prefix
|
|
233
|
+
// bytes appear at position `len - 2 - candidateLen`. Read those 2
|
|
234
|
+
// bytes BE; if they equal candidateLen and candidateLen ≤ AAD_MAX,
|
|
235
|
+
// accept.
|
|
236
|
+
let aadLen = -1;
|
|
237
|
+
let prefixOffset = -1;
|
|
238
|
+
for (let cand = 1; cand <= PAIR_AAD_MAX_LEN; cand += 1) {
|
|
239
|
+
const candPrefixOffset = bytes.length - cand - PAIR_AAD_LEN_PREFIX;
|
|
240
|
+
if (candPrefixOffset < offset)
|
|
241
|
+
break;
|
|
242
|
+
const hi = bytes[candPrefixOffset];
|
|
243
|
+
const lo = bytes[candPrefixOffset + 1];
|
|
244
|
+
const declared = (hi << 8) | lo;
|
|
245
|
+
if (declared === cand) {
|
|
246
|
+
aadLen = cand;
|
|
247
|
+
prefixOffset = candPrefixOffset;
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (aadLen < 0 || prefixOffset < 0) {
|
|
252
|
+
throw new PairBundleError('AAD_LEN_UNDERFLOW', 'could not locate AAD length prefix');
|
|
253
|
+
}
|
|
254
|
+
const ciphertext = bytes.slice(offset, prefixOffset);
|
|
255
|
+
if (ciphertext.length < 16) {
|
|
256
|
+
throw new PairBundleError('BUNDLE_TOO_SHORT', 'ciphertext shorter than Poly1305 tag');
|
|
257
|
+
}
|
|
258
|
+
const aad = bytes.slice(prefixOffset + PAIR_AAD_LEN_PREFIX, bytes.length);
|
|
259
|
+
if (aad.length !== aadLen) {
|
|
260
|
+
throw new PairBundleError('AAD_LEN_UNDERFLOW', 'AAD length prefix disagrees with trailer');
|
|
261
|
+
}
|
|
262
|
+
return { nonce, ephemeralPublicKey, ciphertext, aad };
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Daemon-side: parse, AAD-verify, decrypt, JSON-parse, validate the
|
|
266
|
+
* pairing response. Returns the validated plaintext on success; throws
|
|
267
|
+
* `PairBundleError` on any failure.
|
|
268
|
+
*
|
|
269
|
+
* Constant-time-ish AAD comparison: tweetnacl's box.open already runs
|
|
270
|
+
* Poly1305 in constant time, and the AAD compare we do is byte-by-byte
|
|
271
|
+
* on a 1KB-bounded buffer — short side-channel exposure is negligible
|
|
272
|
+
* given we're comparing public-known canonical bytes.
|
|
273
|
+
*/
|
|
274
|
+
export function openPairBundleBase64(opts) {
|
|
275
|
+
if (opts.daemonSecretKey.length !== PAIR_SECRET_LEN) {
|
|
276
|
+
throw new PairBundleError('INVALID_KEY', 'daemonSecretKey must be 32 bytes');
|
|
277
|
+
}
|
|
278
|
+
if (opts.encryptedPayloadBase64.length > PAIR_BUNDLE_MAX_BASE64_LEN) {
|
|
279
|
+
throw new PairBundleError('BUNDLE_TOO_LONG', `encryptedPayload exceeds ${PAIR_BUNDLE_MAX_BASE64_LEN} chars`);
|
|
280
|
+
}
|
|
281
|
+
let bytes;
|
|
282
|
+
try {
|
|
283
|
+
bytes = base64ToUint8Array(opts.encryptedPayloadBase64);
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
throw new PairBundleError('BUNDLE_BAD_BASE64', `encryptedPayload is not valid base64: ${err instanceof Error ? err.message : String(err)}`);
|
|
287
|
+
}
|
|
288
|
+
const bundle = parsePairBundle(bytes);
|
|
289
|
+
// AAD verify FIRST — fail fast before doing the box.open work, and
|
|
290
|
+
// surface a precise error code if the trailer was tampered with.
|
|
291
|
+
if (!constantTimeEqual(bundle.aad, opts.expectedAad)) {
|
|
292
|
+
throw new PairBundleError('AAD_MISMATCH', 'pair-response AAD does not match the daemon-rebuilt canonical form');
|
|
293
|
+
}
|
|
294
|
+
const plaintextBytes = nacl.box.open(bundle.ciphertext, bundle.nonce, bundle.ephemeralPublicKey, opts.daemonSecretKey);
|
|
295
|
+
if (!plaintextBytes) {
|
|
296
|
+
throw new PairBundleError('DECRYPT_FAILED', 'box.open returned null — wrong key, tampered ciphertext, or wrong nonce');
|
|
297
|
+
}
|
|
298
|
+
let json;
|
|
299
|
+
try {
|
|
300
|
+
json = JSON.parse(new TextDecoder().decode(plaintextBytes));
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
throw new PairBundleError('PLAINTEXT_NOT_JSON', `decrypted plaintext is not JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
304
|
+
}
|
|
305
|
+
const plaintext = parsePairPlaintext(json);
|
|
306
|
+
if (plaintext.daemonId !== opts.expectedDaemonId) {
|
|
307
|
+
throw new PairBundleError('DAEMON_ID_MISMATCH', `decrypted daemonId (${plaintext.daemonId}) does not match expected (${opts.expectedDaemonId})`);
|
|
308
|
+
}
|
|
309
|
+
return plaintext;
|
|
310
|
+
}
|
|
311
|
+
const PAIR_URL_SCHEME = 'oversky://pair';
|
|
312
|
+
/**
|
|
313
|
+
* Build the URL the daemon prints as a QR code. URL-safe parameters
|
|
314
|
+
* only; the bulk is the public key, which is fine.
|
|
315
|
+
*/
|
|
316
|
+
export function buildPairUrl(fields) {
|
|
317
|
+
const params = new URLSearchParams();
|
|
318
|
+
params.set('pk', fields.publicKeyBase64Url);
|
|
319
|
+
params.set('id', fields.pairId);
|
|
320
|
+
params.set('daemonId', fields.daemonId);
|
|
321
|
+
if (fields.host)
|
|
322
|
+
params.set('host', fields.host);
|
|
323
|
+
if (fields.serverUrl)
|
|
324
|
+
params.set('server', fields.serverUrl);
|
|
325
|
+
params.set('v', String(fields.v ?? 1));
|
|
326
|
+
return `${PAIR_URL_SCHEME}?${params.toString()}`;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Parse a scanned URL back into structured fields. Returns null on any
|
|
330
|
+
* format error so the caller can show a user-friendly "not a pair QR"
|
|
331
|
+
* message rather than a stack trace. Strict validation of every field's
|
|
332
|
+
* shape keeps a hostile QR from steering the mobile at a bad daemonId.
|
|
333
|
+
*/
|
|
334
|
+
export function parsePairUrl(url) {
|
|
335
|
+
if (typeof url !== 'string' || !url.startsWith(`${PAIR_URL_SCHEME}?`))
|
|
336
|
+
return null;
|
|
337
|
+
const query = url.slice(`${PAIR_URL_SCHEME}?`.length);
|
|
338
|
+
let params;
|
|
339
|
+
try {
|
|
340
|
+
params = new URLSearchParams(query);
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
const pk = params.get('pk');
|
|
346
|
+
const id = params.get('id');
|
|
347
|
+
const daemonId = params.get('daemonId');
|
|
348
|
+
if (!pk || !id || !daemonId)
|
|
349
|
+
return null;
|
|
350
|
+
// Public key: 32 bytes base64url unpadded → 43 chars; tolerate padded too.
|
|
351
|
+
if (pk.length < 32 || pk.length > 64 || !/^[A-Za-z0-9_-]+={0,2}$/.test(pk))
|
|
352
|
+
return null;
|
|
353
|
+
// pairId: hex.
|
|
354
|
+
if (id.length < 16 || id.length > 64 || !/^[a-f0-9]+$/.test(id))
|
|
355
|
+
return null;
|
|
356
|
+
// daemonId: ASCII printable, ≤128 chars.
|
|
357
|
+
if (daemonId.length === 0 || daemonId.length > 128 || !/^[\x21-\x7E]+$/.test(daemonId)) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
const host = params.get('host') ?? undefined;
|
|
361
|
+
// eslint-disable-next-line no-control-regex -- intentional: reject hosts containing control chars
|
|
362
|
+
if (host !== undefined && (host.length > 256 || /[\x00-\x1F]/.test(host)))
|
|
363
|
+
return null;
|
|
364
|
+
const serverUrl = params.get('server') ?? undefined;
|
|
365
|
+
if (serverUrl !== undefined) {
|
|
366
|
+
try {
|
|
367
|
+
const u = new URL(serverUrl);
|
|
368
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
369
|
+
return null;
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const v = params.get('v');
|
|
376
|
+
const versionNum = v === null ? 1 : Number(v);
|
|
377
|
+
if (!Number.isInteger(versionNum) || versionNum < 1 || versionNum > 255)
|
|
378
|
+
return null;
|
|
379
|
+
return {
|
|
380
|
+
publicKeyBase64Url: pk,
|
|
381
|
+
pairId: id,
|
|
382
|
+
daemonId,
|
|
383
|
+
host,
|
|
384
|
+
serverUrl,
|
|
385
|
+
v: versionNum,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
// ---------------------------------------------------------------------
|
|
389
|
+
// Encoding helpers — Node + RN compat.
|
|
390
|
+
//
|
|
391
|
+
// Node 16+ supports `Buffer.from(b64, 'base64')`. RN ships a polyfill
|
|
392
|
+
// via `react-native-polyfill-globals`. We avoid `Buffer` entirely and
|
|
393
|
+
// stick to atob/btoa-equivalent pure-byte routines so this module
|
|
394
|
+
// runs unchanged on both platforms.
|
|
395
|
+
// ---------------------------------------------------------------------
|
|
396
|
+
const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
397
|
+
export function uint8ArrayToBase64(bytes) {
|
|
398
|
+
let out = '';
|
|
399
|
+
let i = 0;
|
|
400
|
+
for (; i + 2 < bytes.length; i += 3) {
|
|
401
|
+
const b0 = bytes[i];
|
|
402
|
+
const b1 = bytes[i + 1];
|
|
403
|
+
const b2 = bytes[i + 2];
|
|
404
|
+
out += B64_ALPHABET[b0 >> 2];
|
|
405
|
+
out += B64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)];
|
|
406
|
+
out += B64_ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)];
|
|
407
|
+
out += B64_ALPHABET[b2 & 0x3f];
|
|
408
|
+
}
|
|
409
|
+
if (i < bytes.length) {
|
|
410
|
+
const b0 = bytes[i];
|
|
411
|
+
if (i + 1 === bytes.length) {
|
|
412
|
+
out += B64_ALPHABET[b0 >> 2];
|
|
413
|
+
out += B64_ALPHABET[(b0 & 0x03) << 4];
|
|
414
|
+
out += '==';
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
const b1 = bytes[i + 1];
|
|
418
|
+
out += B64_ALPHABET[b0 >> 2];
|
|
419
|
+
out += B64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)];
|
|
420
|
+
out += B64_ALPHABET[(b1 & 0x0f) << 2];
|
|
421
|
+
out += '=';
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
export function base64ToUint8Array(b64) {
|
|
427
|
+
// Tolerate base64url too — the wire format on /respond uses standard
|
|
428
|
+
// base64, but /poll's `response` field passes through whatever the
|
|
429
|
+
// mobile sent, and we don't want to fail on a missing-pad edge case.
|
|
430
|
+
let s = b64.replace(/-/g, '+').replace(/_/g, '/');
|
|
431
|
+
while (s.length % 4 !== 0)
|
|
432
|
+
s += '=';
|
|
433
|
+
const lookup = new Int16Array(128).fill(-1);
|
|
434
|
+
for (let i = 0; i < B64_ALPHABET.length; i += 1) {
|
|
435
|
+
lookup[B64_ALPHABET.charCodeAt(i)] = i;
|
|
436
|
+
}
|
|
437
|
+
// Strip valid padding.
|
|
438
|
+
let strLen = s.length;
|
|
439
|
+
while (strLen > 0 && s.charCodeAt(strLen - 1) === 61 /* '=' */)
|
|
440
|
+
strLen -= 1;
|
|
441
|
+
const outLen = (strLen * 3) >> 2;
|
|
442
|
+
const out = new Uint8Array(outLen);
|
|
443
|
+
let oi = 0;
|
|
444
|
+
for (let i = 0; i < strLen; i += 4) {
|
|
445
|
+
const c0 = lookup[s.charCodeAt(i)] ?? -1;
|
|
446
|
+
const c1 = i + 1 < strLen ? (lookup[s.charCodeAt(i + 1)] ?? -1) : -1;
|
|
447
|
+
const c2 = i + 2 < strLen ? (lookup[s.charCodeAt(i + 2)] ?? -1) : -1;
|
|
448
|
+
const c3 = i + 3 < strLen ? (lookup[s.charCodeAt(i + 3)] ?? -1) : -1;
|
|
449
|
+
if (c0 < 0 || c1 < 0)
|
|
450
|
+
throw new Error('invalid base64 character');
|
|
451
|
+
out[oi++] = (c0 << 2) | (c1 >> 4);
|
|
452
|
+
if (c2 >= 0)
|
|
453
|
+
out[oi++] = ((c1 & 0x0f) << 4) | (c2 >> 2);
|
|
454
|
+
if (c3 >= 0)
|
|
455
|
+
out[oi++] = ((c2 & 0x03) << 6) | c3;
|
|
456
|
+
}
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
export function uint8ArrayToBase64Url(bytes) {
|
|
460
|
+
return uint8ArrayToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
461
|
+
}
|
|
462
|
+
export function base64UrlToUint8Array(b64url) {
|
|
463
|
+
return base64ToUint8Array(b64url);
|
|
464
|
+
}
|
|
465
|
+
function constantTimeEqual(a, b) {
|
|
466
|
+
if (a.length !== b.length)
|
|
467
|
+
return false;
|
|
468
|
+
let diff = 0;
|
|
469
|
+
for (let i = 0; i < a.length; i += 1)
|
|
470
|
+
diff |= a[i] ^ b[i];
|
|
471
|
+
return diff === 0;
|
|
472
|
+
}
|
|
473
|
+
export function generatePairKeyPair() {
|
|
474
|
+
const kp = nacl.box.keyPair();
|
|
475
|
+
return { publicKey: kp.publicKey, secretKey: kp.secretKey };
|
|
476
|
+
}
|