@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,495 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.PairBundleError = exports.PAIR_AAD_PREFIX = exports.PAIR_AAD_MAX_LEN = exports.PAIR_BUNDLE_MAX_BASE64_LEN = exports.PAIR_AAD_LEN_PREFIX = exports.PAIR_SECRET_LEN = exports.PAIR_PUBKEY_LEN = exports.PAIR_NONCE_LEN = void 0;
7
+ exports.buildPairAad = buildPairAad;
8
+ exports.parsePairPlaintext = parsePairPlaintext;
9
+ exports.assemblePairBundleBase64 = assemblePairBundleBase64;
10
+ exports.parsePairBundle = parsePairBundle;
11
+ exports.openPairBundleBase64 = openPairBundleBase64;
12
+ exports.buildPairUrl = buildPairUrl;
13
+ exports.parsePairUrl = parsePairUrl;
14
+ exports.uint8ArrayToBase64 = uint8ArrayToBase64;
15
+ exports.base64ToUint8Array = base64ToUint8Array;
16
+ exports.uint8ArrayToBase64Url = uint8ArrayToBase64Url;
17
+ exports.base64UrlToUint8Array = base64UrlToUint8Array;
18
+ exports.generatePairKeyPair = generatePairKeyPair;
19
+ /**
20
+ * daemonPair.ts — pure-crypto primitives for the mobile-as-keyholder QR
21
+ * pairing handshake (Tier 1 #2 from Happy lessons).
22
+ *
23
+ * Wire format (matches `api/server/routes/daemonPair.js` header):
24
+ *
25
+ * bundle =
26
+ * nonce (24 bytes) // tweetnacl box nonce
27
+ * ephemeralPk (32 bytes) // mobile-side X25519 public key
28
+ * ciphertext (variable) // tweetnacl box(plaintext, daemonPk, ephSk, nonce)
29
+ * lenAad (2 bytes, big-endian)
30
+ * aad (lenAad bytes, ASCII)
31
+ *
32
+ * encryptedPayload = base64(bundle)
33
+ *
34
+ * Canonical AAD (C4 — bound by the server at /respond, rebuilt by the
35
+ * daemon at /poll for verify):
36
+ *
37
+ * oversky:pair:v1:daemonId=<daemonId>:responseUserId=<userId>
38
+ *
39
+ * Note: this module is platform-agnostic. It does no disk IO, no network
40
+ * IO, and no console IO. The daemon CLI orchestrator (`daemon/src/pair.ts`)
41
+ * imports these primitives; the mobile RN client mirrors the same shapes
42
+ * in TypeScript with its own tweetnacl.
43
+ *
44
+ * Lifetime invariant: the ephemeral X25519 secret key NEVER persists. The
45
+ * mobile generates one per pairing, uses it once, and discards. The daemon
46
+ * generates one per `oversky pair` invocation, uses it for the duration
47
+ * of the polling window, and discards on success/failure/abort.
48
+ */
49
+ const tweetnacl_1 = __importDefault(require("tweetnacl"));
50
+ // ---------------------------------------------------------------------
51
+ // Constants
52
+ // ---------------------------------------------------------------------
53
+ /** Box nonce length per RFC 8439 / NaCl. */
54
+ exports.PAIR_NONCE_LEN = 24;
55
+ /** Curve25519 public key length. */
56
+ exports.PAIR_PUBKEY_LEN = 32;
57
+ /** Curve25519 secret key length. */
58
+ exports.PAIR_SECRET_LEN = 32;
59
+ /** Big-endian uint16 length prefix for the AAD trailer. */
60
+ exports.PAIR_AAD_LEN_PREFIX = 2;
61
+ /** Server validator caps `encryptedPayload` at 8192 chars (base64). */
62
+ exports.PAIR_BUNDLE_MAX_BASE64_LEN = 8192;
63
+ /** Conservative ceiling on the trailer to keep the bundle well under cap. */
64
+ exports.PAIR_AAD_MAX_LEN = 1024;
65
+ /** AAD prefix — version-bumped on any breaking format change. */
66
+ exports.PAIR_AAD_PREFIX = 'oversky:pair:v1';
67
+ class PairBundleError extends Error {
68
+ code;
69
+ constructor(code, message) {
70
+ super(message);
71
+ this.name = 'PairBundleError';
72
+ this.code = code;
73
+ }
74
+ }
75
+ exports.PairBundleError = PairBundleError;
76
+ // ---------------------------------------------------------------------
77
+ // AAD
78
+ // ---------------------------------------------------------------------
79
+ /**
80
+ * Build the canonical AAD bytes for a pairing response. Both sides
81
+ * (mobile-builder and daemon-verifier) call this with identical inputs:
82
+ * - mobile gets `responseUserId` from its own JWT (`req.user.id`)
83
+ * - daemon gets `responseUserId` from the `/poll` response
84
+ *
85
+ * The encoding is ASCII; non-ASCII inputs are rejected so a hostile
86
+ * daemonId/userId can't smuggle bytes that would shift the boundary.
87
+ */
88
+ function buildPairAad(daemonId, responseUserId) {
89
+ if (typeof daemonId !== 'string' || daemonId.length === 0) {
90
+ throw new PairBundleError('AAD_MISMATCH', 'daemonId must be a non-empty string');
91
+ }
92
+ if (typeof responseUserId !== 'string' || responseUserId.length === 0) {
93
+ throw new PairBundleError('AAD_MISMATCH', 'responseUserId must be a non-empty string');
94
+ }
95
+ // Reject any non-ASCII printable byte. This forces the canonical
96
+ // encoding to be representable as 1-byte-per-char so length prefixes
97
+ // are unambiguous and a unicode-rebalancing attack on the trailer
98
+ // can't shift the AAD/ciphertext boundary.
99
+ if (!/^[\x21-\x7E]+$/.test(daemonId) || !/^[\x21-\x7E]+$/.test(responseUserId)) {
100
+ throw new PairBundleError('AAD_MISMATCH', 'daemonId and responseUserId must be ASCII printable (no spaces, no controls)');
101
+ }
102
+ const text = `${exports.PAIR_AAD_PREFIX}:daemonId=${daemonId}:responseUserId=${responseUserId}`;
103
+ if (text.length > exports.PAIR_AAD_MAX_LEN) {
104
+ throw new PairBundleError('AAD_LEN_OVERFLOW', 'AAD exceeds maximum length');
105
+ }
106
+ // Pure ASCII → safe to use TextEncoder (1 byte per char in this range).
107
+ return new TextEncoder().encode(text);
108
+ }
109
+ // ---------------------------------------------------------------------
110
+ // Plaintext shape validation
111
+ // ---------------------------------------------------------------------
112
+ /**
113
+ * Parse and validate the JSON plaintext that came out of the box. We
114
+ * keep this tolerant on output (older daemons may write extra fields)
115
+ * but strict on the invariant fields the daemon needs to persist.
116
+ */
117
+ function parsePairPlaintext(raw) {
118
+ if (!raw || typeof raw !== 'object') {
119
+ throw new PairBundleError('PLAINTEXT_BAD_SHAPE', 'plaintext is not an object');
120
+ }
121
+ const obj = raw;
122
+ if (obj.v !== 1) {
123
+ throw new PairBundleError('PLAINTEXT_BAD_SHAPE', `unsupported pair plaintext version: ${String(obj.v)}`);
124
+ }
125
+ const token = optString(obj.token);
126
+ const opaqueAccessToken = optString(obj.opaqueAccessToken);
127
+ if (!token && !opaqueAccessToken) {
128
+ throw new PairBundleError('PLAINTEXT_BAD_SHAPE', 'plaintext must include at least one of token or opaqueAccessToken');
129
+ }
130
+ const daemonId = optString(obj.daemonId);
131
+ if (!daemonId) {
132
+ throw new PairBundleError('PLAINTEXT_BAD_SHAPE', 'plaintext.daemonId is required');
133
+ }
134
+ const issuedAt = optNumber(obj.issuedAt);
135
+ if (issuedAt === undefined) {
136
+ throw new PairBundleError('PLAINTEXT_BAD_SHAPE', 'plaintext.issuedAt is required');
137
+ }
138
+ return {
139
+ v: 1,
140
+ ...(token ? { token } : {}),
141
+ ...(opaqueAccessToken ? { opaqueAccessToken } : {}),
142
+ expiresAt: optNumber(obj.expiresAt),
143
+ refreshToken: optString(obj.refreshToken),
144
+ refreshExpiresAt: optNumber(obj.refreshExpiresAt),
145
+ daemonId,
146
+ issuedAt,
147
+ };
148
+ }
149
+ function optString(v) {
150
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
151
+ }
152
+ function optNumber(v) {
153
+ return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
154
+ }
155
+ /**
156
+ * Mobile-side: encrypt the plaintext to the daemon's pubkey and frame
157
+ * the result for transport. Returns base64-encoded bundle suitable for
158
+ * the `encryptedPayload` field of `POST /api/auth/pair/respond`.
159
+ */
160
+ function assemblePairBundleBase64(opts) {
161
+ if (opts.daemonPublicKey.length !== exports.PAIR_PUBKEY_LEN) {
162
+ throw new PairBundleError('INVALID_KEY', 'daemonPublicKey must be 32 bytes');
163
+ }
164
+ if (opts.ephemeralSecretKey.length !== exports.PAIR_SECRET_LEN) {
165
+ throw new PairBundleError('INVALID_KEY', 'ephemeralSecretKey must be 32 bytes');
166
+ }
167
+ if (opts.ephemeralPublicKey.length !== exports.PAIR_PUBKEY_LEN) {
168
+ throw new PairBundleError('INVALID_KEY', 'ephemeralPublicKey must be 32 bytes');
169
+ }
170
+ if (opts.aad.length === 0 || opts.aad.length > exports.PAIR_AAD_MAX_LEN) {
171
+ throw new PairBundleError('AAD_LEN_OVERFLOW', 'aad must be 1..1024 bytes');
172
+ }
173
+ const nonce = opts.__nonceForTest ?? tweetnacl_1.default.randomBytes(exports.PAIR_NONCE_LEN);
174
+ if (nonce.length !== exports.PAIR_NONCE_LEN) {
175
+ throw new PairBundleError('INVALID_KEY', 'nonce must be 24 bytes');
176
+ }
177
+ const plaintextJson = JSON.stringify(opts.plaintext);
178
+ const plaintextBytes = new TextEncoder().encode(plaintextJson);
179
+ const ciphertext = tweetnacl_1.default.box(plaintextBytes, nonce, opts.daemonPublicKey, opts.ephemeralSecretKey);
180
+ const totalLen = exports.PAIR_NONCE_LEN + exports.PAIR_PUBKEY_LEN + ciphertext.length + exports.PAIR_AAD_LEN_PREFIX + opts.aad.length;
181
+ const bundle = new Uint8Array(totalLen);
182
+ let offset = 0;
183
+ bundle.set(nonce, offset);
184
+ offset += exports.PAIR_NONCE_LEN;
185
+ bundle.set(opts.ephemeralPublicKey, offset);
186
+ offset += exports.PAIR_PUBKEY_LEN;
187
+ bundle.set(ciphertext, offset);
188
+ offset += ciphertext.length;
189
+ // Big-endian uint16 length prefix for AAD.
190
+ bundle[offset] = (opts.aad.length >>> 8) & 0xff;
191
+ bundle[offset + 1] = opts.aad.length & 0xff;
192
+ offset += exports.PAIR_AAD_LEN_PREFIX;
193
+ bundle.set(opts.aad, offset);
194
+ const base64 = uint8ArrayToBase64(bundle);
195
+ if (base64.length > exports.PAIR_BUNDLE_MAX_BASE64_LEN) {
196
+ throw new PairBundleError('BUNDLE_TOO_LONG', `encryptedPayload exceeds ${exports.PAIR_BUNDLE_MAX_BASE64_LEN} chars`);
197
+ }
198
+ return base64;
199
+ }
200
+ // ---------------------------------------------------------------------
201
+ // Bundle parse + decrypt + AAD verify (daemon side)
202
+ // ---------------------------------------------------------------------
203
+ /**
204
+ * Parse the wire bytes into a `PairBundle` without decrypting. Used as
205
+ * a preflight by `openPairBundleBase64` and exposed for tests.
206
+ */
207
+ function parsePairBundle(bytes) {
208
+ const minLen = exports.PAIR_NONCE_LEN + exports.PAIR_PUBKEY_LEN + exports.PAIR_AAD_LEN_PREFIX + 16;
209
+ if (bytes.length < minLen) {
210
+ throw new PairBundleError('BUNDLE_TOO_SHORT', `bundle is ${bytes.length} bytes, need at least ${minLen}`);
211
+ }
212
+ let offset = 0;
213
+ const nonce = bytes.slice(offset, offset + exports.PAIR_NONCE_LEN);
214
+ offset += exports.PAIR_NONCE_LEN;
215
+ const ephemeralPublicKey = bytes.slice(offset, offset + exports.PAIR_PUBKEY_LEN);
216
+ offset += exports.PAIR_PUBKEY_LEN;
217
+ // The AAD is at the END of the bundle (length-prefixed). Walk backwards
218
+ // from the tail so the remainder is unambiguously the ciphertext.
219
+ if (bytes.length - offset < exports.PAIR_AAD_LEN_PREFIX + 1) {
220
+ throw new PairBundleError('BUNDLE_TOO_SHORT', 'no room for AAD trailer');
221
+ }
222
+ // Find the AAD-length prefix by walking forward through ciphertext +
223
+ // trailer. The ciphertext length is unknown until we know the AAD
224
+ // length. Simplest deterministic decode: assume the trailer is at the
225
+ // end and that lenAad fits the remaining bytes exactly.
226
+ //
227
+ // Specifically: the last (lenAad) bytes are AAD; the 2 bytes before
228
+ // that are the big-endian length; everything between offset and
229
+ // (bytes.length - lenAad - 2) is ciphertext.
230
+ if (bytes.length < offset + exports.PAIR_AAD_LEN_PREFIX) {
231
+ throw new PairBundleError('BUNDLE_TOO_SHORT', 'no room for AAD length prefix');
232
+ }
233
+ // We need to walk: pick the lenAad as the last 2 bytes WITH the
234
+ // tail bytes after them — that means the prefix is at
235
+ // bytes.length - 2 - lenAad. But we don't know lenAad yet.
236
+ //
237
+ // The bundle layout puts the prefix BEFORE the AAD bytes, so the
238
+ // prefix is at `bytes.length - lenAad - 2`. The ONLY consistent
239
+ // decode is: lenAad = u16BE(bytes[bytes.length - 2 - L : ...]) where
240
+ // we walk L from 0 upward. The simpler and equally-correct approach:
241
+ // require the writer to ALSO put a trailer-length at the tail. But
242
+ // we don't — so we adopt the convention that lenAad lives
243
+ // immediately AFTER the ciphertext, which means we need the writer
244
+ // and reader to agree on where the ciphertext ends.
245
+ //
246
+ // The agreement: lenAad is encoded at offset
247
+ // bytes.length - lenAad - 2
248
+ // and the AAD itself is the last `lenAad` bytes. To find lenAad we
249
+ // first read the last 2 bytes? No — those are the tail of the AAD.
250
+ //
251
+ // Concrete decoder: walk lenAad candidates from the end. The prefix
252
+ // bytes appear at position `len - 2 - candidateLen`. Read those 2
253
+ // bytes BE; if they equal candidateLen and candidateLen ≤ AAD_MAX,
254
+ // accept.
255
+ let aadLen = -1;
256
+ let prefixOffset = -1;
257
+ for (let cand = 1; cand <= exports.PAIR_AAD_MAX_LEN; cand += 1) {
258
+ const candPrefixOffset = bytes.length - cand - exports.PAIR_AAD_LEN_PREFIX;
259
+ if (candPrefixOffset < offset)
260
+ break;
261
+ const hi = bytes[candPrefixOffset];
262
+ const lo = bytes[candPrefixOffset + 1];
263
+ const declared = (hi << 8) | lo;
264
+ if (declared === cand) {
265
+ aadLen = cand;
266
+ prefixOffset = candPrefixOffset;
267
+ break;
268
+ }
269
+ }
270
+ if (aadLen < 0 || prefixOffset < 0) {
271
+ throw new PairBundleError('AAD_LEN_UNDERFLOW', 'could not locate AAD length prefix');
272
+ }
273
+ const ciphertext = bytes.slice(offset, prefixOffset);
274
+ if (ciphertext.length < 16) {
275
+ throw new PairBundleError('BUNDLE_TOO_SHORT', 'ciphertext shorter than Poly1305 tag');
276
+ }
277
+ const aad = bytes.slice(prefixOffset + exports.PAIR_AAD_LEN_PREFIX, bytes.length);
278
+ if (aad.length !== aadLen) {
279
+ throw new PairBundleError('AAD_LEN_UNDERFLOW', 'AAD length prefix disagrees with trailer');
280
+ }
281
+ return { nonce, ephemeralPublicKey, ciphertext, aad };
282
+ }
283
+ /**
284
+ * Daemon-side: parse, AAD-verify, decrypt, JSON-parse, validate the
285
+ * pairing response. Returns the validated plaintext on success; throws
286
+ * `PairBundleError` on any failure.
287
+ *
288
+ * Constant-time-ish AAD comparison: tweetnacl's box.open already runs
289
+ * Poly1305 in constant time, and the AAD compare we do is byte-by-byte
290
+ * on a 1KB-bounded buffer — short side-channel exposure is negligible
291
+ * given we're comparing public-known canonical bytes.
292
+ */
293
+ function openPairBundleBase64(opts) {
294
+ if (opts.daemonSecretKey.length !== exports.PAIR_SECRET_LEN) {
295
+ throw new PairBundleError('INVALID_KEY', 'daemonSecretKey must be 32 bytes');
296
+ }
297
+ if (opts.encryptedPayloadBase64.length > exports.PAIR_BUNDLE_MAX_BASE64_LEN) {
298
+ throw new PairBundleError('BUNDLE_TOO_LONG', `encryptedPayload exceeds ${exports.PAIR_BUNDLE_MAX_BASE64_LEN} chars`);
299
+ }
300
+ let bytes;
301
+ try {
302
+ bytes = base64ToUint8Array(opts.encryptedPayloadBase64);
303
+ }
304
+ catch (err) {
305
+ throw new PairBundleError('BUNDLE_BAD_BASE64', `encryptedPayload is not valid base64: ${err instanceof Error ? err.message : String(err)}`);
306
+ }
307
+ const bundle = parsePairBundle(bytes);
308
+ // AAD verify FIRST — fail fast before doing the box.open work, and
309
+ // surface a precise error code if the trailer was tampered with.
310
+ if (!constantTimeEqual(bundle.aad, opts.expectedAad)) {
311
+ throw new PairBundleError('AAD_MISMATCH', 'pair-response AAD does not match the daemon-rebuilt canonical form');
312
+ }
313
+ const plaintextBytes = tweetnacl_1.default.box.open(bundle.ciphertext, bundle.nonce, bundle.ephemeralPublicKey, opts.daemonSecretKey);
314
+ if (!plaintextBytes) {
315
+ throw new PairBundleError('DECRYPT_FAILED', 'box.open returned null — wrong key, tampered ciphertext, or wrong nonce');
316
+ }
317
+ let json;
318
+ try {
319
+ json = JSON.parse(new TextDecoder().decode(plaintextBytes));
320
+ }
321
+ catch (err) {
322
+ throw new PairBundleError('PLAINTEXT_NOT_JSON', `decrypted plaintext is not JSON: ${err instanceof Error ? err.message : String(err)}`);
323
+ }
324
+ const plaintext = parsePairPlaintext(json);
325
+ if (plaintext.daemonId !== opts.expectedDaemonId) {
326
+ throw new PairBundleError('DAEMON_ID_MISMATCH', `decrypted daemonId (${plaintext.daemonId}) does not match expected (${opts.expectedDaemonId})`);
327
+ }
328
+ return plaintext;
329
+ }
330
+ const PAIR_URL_SCHEME = 'oversky://pair';
331
+ /**
332
+ * Build the URL the daemon prints as a QR code. URL-safe parameters
333
+ * only; the bulk is the public key, which is fine.
334
+ */
335
+ function buildPairUrl(fields) {
336
+ const params = new URLSearchParams();
337
+ params.set('pk', fields.publicKeyBase64Url);
338
+ params.set('id', fields.pairId);
339
+ params.set('daemonId', fields.daemonId);
340
+ if (fields.host)
341
+ params.set('host', fields.host);
342
+ if (fields.serverUrl)
343
+ params.set('server', fields.serverUrl);
344
+ params.set('v', String(fields.v ?? 1));
345
+ return `${PAIR_URL_SCHEME}?${params.toString()}`;
346
+ }
347
+ /**
348
+ * Parse a scanned URL back into structured fields. Returns null on any
349
+ * format error so the caller can show a user-friendly "not a pair QR"
350
+ * message rather than a stack trace. Strict validation of every field's
351
+ * shape keeps a hostile QR from steering the mobile at a bad daemonId.
352
+ */
353
+ function parsePairUrl(url) {
354
+ if (typeof url !== 'string' || !url.startsWith(`${PAIR_URL_SCHEME}?`))
355
+ return null;
356
+ const query = url.slice(`${PAIR_URL_SCHEME}?`.length);
357
+ let params;
358
+ try {
359
+ params = new URLSearchParams(query);
360
+ }
361
+ catch {
362
+ return null;
363
+ }
364
+ const pk = params.get('pk');
365
+ const id = params.get('id');
366
+ const daemonId = params.get('daemonId');
367
+ if (!pk || !id || !daemonId)
368
+ return null;
369
+ // Public key: 32 bytes base64url unpadded → 43 chars; tolerate padded too.
370
+ if (pk.length < 32 || pk.length > 64 || !/^[A-Za-z0-9_-]+={0,2}$/.test(pk))
371
+ return null;
372
+ // pairId: hex.
373
+ if (id.length < 16 || id.length > 64 || !/^[a-f0-9]+$/.test(id))
374
+ return null;
375
+ // daemonId: ASCII printable, ≤128 chars.
376
+ if (daemonId.length === 0 || daemonId.length > 128 || !/^[\x21-\x7E]+$/.test(daemonId)) {
377
+ return null;
378
+ }
379
+ const host = params.get('host') ?? undefined;
380
+ // eslint-disable-next-line no-control-regex -- intentional: reject hosts containing control chars
381
+ if (host !== undefined && (host.length > 256 || /[\x00-\x1F]/.test(host)))
382
+ return null;
383
+ const serverUrl = params.get('server') ?? undefined;
384
+ if (serverUrl !== undefined) {
385
+ try {
386
+ const u = new URL(serverUrl);
387
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
388
+ return null;
389
+ }
390
+ catch {
391
+ return null;
392
+ }
393
+ }
394
+ const v = params.get('v');
395
+ const versionNum = v === null ? 1 : Number(v);
396
+ if (!Number.isInteger(versionNum) || versionNum < 1 || versionNum > 255)
397
+ return null;
398
+ return {
399
+ publicKeyBase64Url: pk,
400
+ pairId: id,
401
+ daemonId,
402
+ host,
403
+ serverUrl,
404
+ v: versionNum,
405
+ };
406
+ }
407
+ // ---------------------------------------------------------------------
408
+ // Encoding helpers — Node + RN compat.
409
+ //
410
+ // Node 16+ supports `Buffer.from(b64, 'base64')`. RN ships a polyfill
411
+ // via `react-native-polyfill-globals`. We avoid `Buffer` entirely and
412
+ // stick to atob/btoa-equivalent pure-byte routines so this module
413
+ // runs unchanged on both platforms.
414
+ // ---------------------------------------------------------------------
415
+ const B64_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
416
+ function uint8ArrayToBase64(bytes) {
417
+ let out = '';
418
+ let i = 0;
419
+ for (; i + 2 < bytes.length; i += 3) {
420
+ const b0 = bytes[i];
421
+ const b1 = bytes[i + 1];
422
+ const b2 = bytes[i + 2];
423
+ out += B64_ALPHABET[b0 >> 2];
424
+ out += B64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)];
425
+ out += B64_ALPHABET[((b1 & 0x0f) << 2) | (b2 >> 6)];
426
+ out += B64_ALPHABET[b2 & 0x3f];
427
+ }
428
+ if (i < bytes.length) {
429
+ const b0 = bytes[i];
430
+ if (i + 1 === bytes.length) {
431
+ out += B64_ALPHABET[b0 >> 2];
432
+ out += B64_ALPHABET[(b0 & 0x03) << 4];
433
+ out += '==';
434
+ }
435
+ else {
436
+ const b1 = bytes[i + 1];
437
+ out += B64_ALPHABET[b0 >> 2];
438
+ out += B64_ALPHABET[((b0 & 0x03) << 4) | (b1 >> 4)];
439
+ out += B64_ALPHABET[(b1 & 0x0f) << 2];
440
+ out += '=';
441
+ }
442
+ }
443
+ return out;
444
+ }
445
+ function base64ToUint8Array(b64) {
446
+ // Tolerate base64url too — the wire format on /respond uses standard
447
+ // base64, but /poll's `response` field passes through whatever the
448
+ // mobile sent, and we don't want to fail on a missing-pad edge case.
449
+ let s = b64.replace(/-/g, '+').replace(/_/g, '/');
450
+ while (s.length % 4 !== 0)
451
+ s += '=';
452
+ const lookup = new Int16Array(128).fill(-1);
453
+ for (let i = 0; i < B64_ALPHABET.length; i += 1) {
454
+ lookup[B64_ALPHABET.charCodeAt(i)] = i;
455
+ }
456
+ // Strip valid padding.
457
+ let strLen = s.length;
458
+ while (strLen > 0 && s.charCodeAt(strLen - 1) === 61 /* '=' */)
459
+ strLen -= 1;
460
+ const outLen = (strLen * 3) >> 2;
461
+ const out = new Uint8Array(outLen);
462
+ let oi = 0;
463
+ for (let i = 0; i < strLen; i += 4) {
464
+ const c0 = lookup[s.charCodeAt(i)] ?? -1;
465
+ const c1 = i + 1 < strLen ? (lookup[s.charCodeAt(i + 1)] ?? -1) : -1;
466
+ const c2 = i + 2 < strLen ? (lookup[s.charCodeAt(i + 2)] ?? -1) : -1;
467
+ const c3 = i + 3 < strLen ? (lookup[s.charCodeAt(i + 3)] ?? -1) : -1;
468
+ if (c0 < 0 || c1 < 0)
469
+ throw new Error('invalid base64 character');
470
+ out[oi++] = (c0 << 2) | (c1 >> 4);
471
+ if (c2 >= 0)
472
+ out[oi++] = ((c1 & 0x0f) << 4) | (c2 >> 2);
473
+ if (c3 >= 0)
474
+ out[oi++] = ((c2 & 0x03) << 6) | c3;
475
+ }
476
+ return out;
477
+ }
478
+ function uint8ArrayToBase64Url(bytes) {
479
+ return uint8ArrayToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
480
+ }
481
+ function base64UrlToUint8Array(b64url) {
482
+ return base64ToUint8Array(b64url);
483
+ }
484
+ function constantTimeEqual(a, b) {
485
+ if (a.length !== b.length)
486
+ return false;
487
+ let diff = 0;
488
+ for (let i = 0; i < a.length; i += 1)
489
+ diff |= a[i] ^ b[i];
490
+ return diff === 0;
491
+ }
492
+ function generatePairKeyPair() {
493
+ const kp = tweetnacl_1.default.box.keyPair();
494
+ return { publicKey: kp.publicKey, secretKey: kp.secretKey };
495
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * daemonScopes.ts — daemon-capability scope vocabulary for the
3
+ * non-Mongoose tier.
4
+ *
5
+ * The schema-bound canonical home is
6
+ * `@skrr-ai/data-schemas/common/daemonScopes` — that file owns the
7
+ * Mongoose validators (`VALID_SCOPE_SET`, schema enum allowlists), the
8
+ * consent-screen group metadata (`SCOPE_GROUPS`), and the helper
9
+ * functions that the API server depends on at runtime.
10
+ *
11
+ * This file is the parallel home for tiers that cannot import
12
+ * `@skrr-ai/data-schemas` without dragging Mongoose into their
13
+ * bundle:
14
+ * - `daemon/` (Node CLI / standalone binary, ships via Bun compile)
15
+ * - `desktop/` (Electron main process, lean bundle)
16
+ * - `cli/` (oclif Sky CLI)
17
+ *
18
+ * The two homes are kept byte-equal at CI time by
19
+ * `api/server/services/Auth/__tests__/daemonScopes.parity.spec.js`.
20
+ * A new scope MUST be added in BOTH files; the parity test fails on
21
+ * any drift before merge.
22
+ *
23
+ * # Vocabulary stability
24
+ *
25
+ * The six strings are user-visible (consent screens) and persisted
26
+ * (JWT claims, Mongo column values, Redis hash fields, audit logs).
27
+ * Renaming a scope is a breaking change for every credential already
28
+ * minted with the old value. Add new scopes, deprecate old ones —
29
+ * never rename in place.
30
+ */
31
+ /**
32
+ * The canonical daemon scope vocabulary. Six strings, frozen.
33
+ *
34
+ * - `daemon:tools:bash` — bash tool execution
35
+ * - `daemon:tools:fs:read` — Read tool (file read)
36
+ * - `daemon:tools:fs:write` — Write + Edit tools (file mutation)
37
+ * - `daemon:browser:cdp` — browser automation via CDP (reserved)
38
+ * - `daemon:session:replay` — `daemon:session:replay` socket event
39
+ * - `daemon:offline-queue` — daemon-initiated offline queue
40
+ *
41
+ * Order is the consent-screen render order: tools-first, I/O second,
42
+ * control-flow last.
43
+ */
44
+ export declare const SCOPES: Readonly<{
45
+ readonly TOOLS_BASH: "daemon:tools:bash";
46
+ readonly TOOLS_FS_READ: "daemon:tools:fs:read";
47
+ readonly TOOLS_FS_WRITE: "daemon:tools:fs:write";
48
+ readonly BROWSER_CDP: "daemon:browser:cdp";
49
+ readonly SESSION_REPLAY: "daemon:session:replay";
50
+ readonly OFFLINE_QUEUE: "daemon:offline-queue";
51
+ }>;
52
+ export type DaemonScope = (typeof SCOPES)[keyof typeof SCOPES];
53
+ /**
54
+ * The six daemon scopes in canonical order. Frozen so consumers can
55
+ * pass it directly to OAuth `scope` parameters without defensive
56
+ * copies.
57
+ */
58
+ export declare const ALL_DAEMON_SCOPES: readonly DaemonScope[];
59
+ /**
60
+ * Set form of `ALL_DAEMON_SCOPES` for O(1) membership checks.
61
+ */
62
+ export declare const VALID_DAEMON_SCOPE_SET: ReadonlySet<string>;
63
+ /** Type guard for canonical daemon-scope strings. */
64
+ export declare function isDaemonScope(s: unknown): s is DaemonScope;
65
+ /**
66
+ * `cli` — REST-only sign-in grant for first-party CLI clients. Peer
67
+ * to the six daemon scopes; lives on `OAuthClient.allowedScopes` for
68
+ * the `oversky-cli` client. See the data-schemas canonical for the
69
+ * full reasoning behind keeping this OUT of `ALL_DAEMON_SCOPES` (the
70
+ * grandfather sentinel `expandScopes(undefined)` would over-grant).
71
+ */
72
+ export declare const OAUTH_CLI_SCOPE: "cli";
73
+ export type OAuthCliScope = typeof OAUTH_CLI_SCOPE;
74
+ export type OAuthScope = DaemonScope | OAuthCliScope;
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+ /**
3
+ * daemonScopes.ts — daemon-capability scope vocabulary for the
4
+ * non-Mongoose tier.
5
+ *
6
+ * The schema-bound canonical home is
7
+ * `@skrr-ai/data-schemas/common/daemonScopes` — that file owns the
8
+ * Mongoose validators (`VALID_SCOPE_SET`, schema enum allowlists), the
9
+ * consent-screen group metadata (`SCOPE_GROUPS`), and the helper
10
+ * functions that the API server depends on at runtime.
11
+ *
12
+ * This file is the parallel home for tiers that cannot import
13
+ * `@skrr-ai/data-schemas` without dragging Mongoose into their
14
+ * bundle:
15
+ * - `daemon/` (Node CLI / standalone binary, ships via Bun compile)
16
+ * - `desktop/` (Electron main process, lean bundle)
17
+ * - `cli/` (oclif Sky CLI)
18
+ *
19
+ * The two homes are kept byte-equal at CI time by
20
+ * `api/server/services/Auth/__tests__/daemonScopes.parity.spec.js`.
21
+ * A new scope MUST be added in BOTH files; the parity test fails on
22
+ * any drift before merge.
23
+ *
24
+ * # Vocabulary stability
25
+ *
26
+ * The six strings are user-visible (consent screens) and persisted
27
+ * (JWT claims, Mongo column values, Redis hash fields, audit logs).
28
+ * Renaming a scope is a breaking change for every credential already
29
+ * minted with the old value. Add new scopes, deprecate old ones —
30
+ * never rename in place.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ exports.OAUTH_CLI_SCOPE = exports.VALID_DAEMON_SCOPE_SET = exports.ALL_DAEMON_SCOPES = exports.SCOPES = void 0;
34
+ exports.isDaemonScope = isDaemonScope;
35
+ /**
36
+ * The canonical daemon scope vocabulary. Six strings, frozen.
37
+ *
38
+ * - `daemon:tools:bash` — bash tool execution
39
+ * - `daemon:tools:fs:read` — Read tool (file read)
40
+ * - `daemon:tools:fs:write` — Write + Edit tools (file mutation)
41
+ * - `daemon:browser:cdp` — browser automation via CDP (reserved)
42
+ * - `daemon:session:replay` — `daemon:session:replay` socket event
43
+ * - `daemon:offline-queue` — daemon-initiated offline queue
44
+ *
45
+ * Order is the consent-screen render order: tools-first, I/O second,
46
+ * control-flow last.
47
+ */
48
+ exports.SCOPES = Object.freeze({
49
+ TOOLS_BASH: 'daemon:tools:bash',
50
+ TOOLS_FS_READ: 'daemon:tools:fs:read',
51
+ TOOLS_FS_WRITE: 'daemon:tools:fs:write',
52
+ BROWSER_CDP: 'daemon:browser:cdp',
53
+ SESSION_REPLAY: 'daemon:session:replay',
54
+ OFFLINE_QUEUE: 'daemon:offline-queue',
55
+ });
56
+ /**
57
+ * The six daemon scopes in canonical order. Frozen so consumers can
58
+ * pass it directly to OAuth `scope` parameters without defensive
59
+ * copies.
60
+ */
61
+ exports.ALL_DAEMON_SCOPES = Object.freeze([
62
+ exports.SCOPES.TOOLS_BASH,
63
+ exports.SCOPES.TOOLS_FS_READ,
64
+ exports.SCOPES.TOOLS_FS_WRITE,
65
+ exports.SCOPES.BROWSER_CDP,
66
+ exports.SCOPES.SESSION_REPLAY,
67
+ exports.SCOPES.OFFLINE_QUEUE,
68
+ ]);
69
+ /**
70
+ * Set form of `ALL_DAEMON_SCOPES` for O(1) membership checks.
71
+ */
72
+ exports.VALID_DAEMON_SCOPE_SET = new Set(exports.ALL_DAEMON_SCOPES);
73
+ /** Type guard for canonical daemon-scope strings. */
74
+ function isDaemonScope(s) {
75
+ return typeof s === 'string' && exports.VALID_DAEMON_SCOPE_SET.has(s);
76
+ }
77
+ /**
78
+ * `cli` — REST-only sign-in grant for first-party CLI clients. Peer
79
+ * to the six daemon scopes; lives on `OAuthClient.allowedScopes` for
80
+ * the `oversky-cli` client. See the data-schemas canonical for the
81
+ * full reasoning behind keeping this OUT of `ALL_DAEMON_SCOPES` (the
82
+ * grandfather sentinel `expandScopes(undefined)` would over-grant).
83
+ */
84
+ exports.OAUTH_CLI_SCOPE = 'cli';