@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.
Files changed (147) hide show
  1. package/README.md +21 -0
  2. package/dist/cjs/aead.d.ts +24 -0
  3. package/dist/cjs/aead.js +65 -0
  4. package/dist/cjs/authExpiredDetector.d.ts +65 -0
  5. package/dist/cjs/authExpiredDetector.js +89 -0
  6. package/dist/cjs/authHelper.d.ts +51 -0
  7. package/dist/cjs/authHelper.js +321 -0
  8. package/dist/cjs/credentialEnvelope.d.ts +85 -0
  9. package/dist/cjs/credentialEnvelope.js +230 -0
  10. package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
  11. package/dist/cjs/credentialEnvelopeBridge.js +637 -0
  12. package/dist/cjs/credentialResolver.d.ts +278 -0
  13. package/dist/cjs/credentialResolver.js +423 -0
  14. package/dist/cjs/daemonPair.d.ts +172 -0
  15. package/dist/cjs/daemonPair.js +495 -0
  16. package/dist/cjs/daemonScopes.d.ts +74 -0
  17. package/dist/cjs/daemonScopes.js +84 -0
  18. package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
  19. package/dist/cjs/deviceIdentityBridge.js +591 -0
  20. package/dist/cjs/deviceKey.d.ts +167 -0
  21. package/dist/cjs/deviceKey.js +332 -0
  22. package/dist/cjs/fdToken.d.ts +17 -0
  23. package/dist/cjs/fdToken.js +238 -0
  24. package/dist/cjs/harnessTrust.d.ts +72 -0
  25. package/dist/cjs/harnessTrust.js +103 -0
  26. package/dist/cjs/headers-helper.d.ts +25 -0
  27. package/dist/cjs/headers-helper.js +323 -0
  28. package/dist/cjs/index.d.ts +37 -0
  29. package/dist/cjs/index.js +305 -0
  30. package/dist/cjs/jwtUtils.d.ts +35 -0
  31. package/dist/cjs/jwtUtils.js +61 -0
  32. package/dist/cjs/kek/index.d.ts +89 -0
  33. package/dist/cjs/kek/index.js +208 -0
  34. package/dist/cjs/kek/linux.d.ts +92 -0
  35. package/dist/cjs/kek/linux.js +596 -0
  36. package/dist/cjs/kek/macos.d.ts +112 -0
  37. package/dist/cjs/kek/macos.js +599 -0
  38. package/dist/cjs/kek/types.d.ts +111 -0
  39. package/dist/cjs/kek/types.js +43 -0
  40. package/dist/cjs/kek/windows.d.ts +71 -0
  41. package/dist/cjs/kek/windows.js +350 -0
  42. package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
  43. package/dist/cjs/kek/zeroize-registry.js +63 -0
  44. package/dist/cjs/legacyStatePreflight.d.ts +67 -0
  45. package/dist/cjs/legacyStatePreflight.js +78 -0
  46. package/dist/cjs/localIdentity.d.ts +54 -0
  47. package/dist/cjs/localIdentity.js +57 -0
  48. package/dist/cjs/loginLocalhost.d.ts +115 -0
  49. package/dist/cjs/loginLocalhost.js +368 -0
  50. package/dist/cjs/loginWithLocalhost.d.ts +131 -0
  51. package/dist/cjs/loginWithLocalhost.js +359 -0
  52. package/dist/cjs/machineId.d.ts +35 -0
  53. package/dist/cjs/machineId.js +239 -0
  54. package/dist/cjs/messages.d.ts +9 -0
  55. package/dist/cjs/messages.js +44 -0
  56. package/dist/cjs/package.json +3 -0
  57. package/dist/cjs/pkce.d.ts +62 -0
  58. package/dist/cjs/pkce.js +158 -0
  59. package/dist/cjs/recoveryCode.d.ts +61 -0
  60. package/dist/cjs/recoveryCode.js +213 -0
  61. package/dist/cjs/refresh.d.ts +215 -0
  62. package/dist/cjs/refresh.js +877 -0
  63. package/dist/cjs/refreshClassification.d.ts +53 -0
  64. package/dist/cjs/refreshClassification.js +105 -0
  65. package/dist/cjs/refreshScheduler.d.ts +53 -0
  66. package/dist/cjs/refreshScheduler.js +332 -0
  67. package/dist/cjs/runtime.d.ts +149 -0
  68. package/dist/cjs/runtime.js +136 -0
  69. package/dist/cjs/spawnEnv.d.ts +76 -0
  70. package/dist/cjs/spawnEnv.js +153 -0
  71. package/dist/cjs/ttlParser.d.ts +54 -0
  72. package/dist/cjs/ttlParser.js +87 -0
  73. package/dist/cjs/types.d.ts +91 -0
  74. package/dist/cjs/types.js +26 -0
  75. package/dist/esm/aead.d.ts +24 -0
  76. package/dist/esm/aead.js +57 -0
  77. package/dist/esm/authExpiredDetector.d.ts +65 -0
  78. package/dist/esm/authExpiredDetector.js +85 -0
  79. package/dist/esm/authHelper.d.ts +51 -0
  80. package/dist/esm/authHelper.js +311 -0
  81. package/dist/esm/credentialEnvelope.d.ts +85 -0
  82. package/dist/esm/credentialEnvelope.js +213 -0
  83. package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
  84. package/dist/esm/credentialEnvelopeBridge.js +620 -0
  85. package/dist/esm/credentialResolver.d.ts +278 -0
  86. package/dist/esm/credentialResolver.js +414 -0
  87. package/dist/esm/daemonPair.d.ts +172 -0
  88. package/dist/esm/daemonPair.js +476 -0
  89. package/dist/esm/daemonScopes.d.ts +74 -0
  90. package/dist/esm/daemonScopes.js +80 -0
  91. package/dist/esm/deviceIdentityBridge.d.ts +113 -0
  92. package/dist/esm/deviceIdentityBridge.js +572 -0
  93. package/dist/esm/deviceKey.d.ts +167 -0
  94. package/dist/esm/deviceKey.js +320 -0
  95. package/dist/esm/fdToken.d.ts +17 -0
  96. package/dist/esm/fdToken.js +231 -0
  97. package/dist/esm/harnessTrust.d.ts +72 -0
  98. package/dist/esm/harnessTrust.js +97 -0
  99. package/dist/esm/headers-helper.d.ts +25 -0
  100. package/dist/esm/headers-helper.js +316 -0
  101. package/dist/esm/index.d.ts +37 -0
  102. package/dist/esm/index.js +144 -0
  103. package/dist/esm/jwtUtils.d.ts +35 -0
  104. package/dist/esm/jwtUtils.js +57 -0
  105. package/dist/esm/kek/index.d.ts +89 -0
  106. package/dist/esm/kek/index.js +162 -0
  107. package/dist/esm/kek/linux.d.ts +92 -0
  108. package/dist/esm/kek/linux.js +585 -0
  109. package/dist/esm/kek/macos.d.ts +112 -0
  110. package/dist/esm/kek/macos.js +586 -0
  111. package/dist/esm/kek/types.d.ts +111 -0
  112. package/dist/esm/kek/types.js +39 -0
  113. package/dist/esm/kek/windows.d.ts +71 -0
  114. package/dist/esm/kek/windows.js +344 -0
  115. package/dist/esm/kek/zeroize-registry.d.ts +44 -0
  116. package/dist/esm/kek/zeroize-registry.js +58 -0
  117. package/dist/esm/legacyStatePreflight.d.ts +67 -0
  118. package/dist/esm/legacyStatePreflight.js +71 -0
  119. package/dist/esm/localIdentity.d.ts +54 -0
  120. package/dist/esm/localIdentity.js +54 -0
  121. package/dist/esm/loginLocalhost.d.ts +115 -0
  122. package/dist/esm/loginLocalhost.js +355 -0
  123. package/dist/esm/loginWithLocalhost.d.ts +131 -0
  124. package/dist/esm/loginWithLocalhost.js +353 -0
  125. package/dist/esm/machineId.d.ts +35 -0
  126. package/dist/esm/machineId.js +231 -0
  127. package/dist/esm/messages.d.ts +9 -0
  128. package/dist/esm/messages.js +40 -0
  129. package/dist/esm/pkce.d.ts +62 -0
  130. package/dist/esm/pkce.js +148 -0
  131. package/dist/esm/recoveryCode.d.ts +61 -0
  132. package/dist/esm/recoveryCode.js +207 -0
  133. package/dist/esm/refresh.d.ts +215 -0
  134. package/dist/esm/refresh.js +863 -0
  135. package/dist/esm/refreshClassification.d.ts +53 -0
  136. package/dist/esm/refreshClassification.js +106 -0
  137. package/dist/esm/refreshScheduler.d.ts +53 -0
  138. package/dist/esm/refreshScheduler.js +329 -0
  139. package/dist/esm/runtime.d.ts +149 -0
  140. package/dist/esm/runtime.js +121 -0
  141. package/dist/esm/spawnEnv.d.ts +76 -0
  142. package/dist/esm/spawnEnv.js +149 -0
  143. package/dist/esm/ttlParser.d.ts +54 -0
  144. package/dist/esm/ttlParser.js +83 -0
  145. package/dist/esm/types.d.ts +91 -0
  146. package/dist/esm/types.js +21 -0
  147. 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
+ }