slybrowser 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/service.js CHANGED
@@ -1,6 +1,5 @@
1
- import { spawn } from "node:child_process";
2
- import { createDecipheriv, createHash, createPublicKey, randomUUID, scryptSync, verify as verifySignature } from "node:crypto";
3
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { createPublicKey, randomUUID, verify as verifySignature } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
4
3
  import { resolve } from "node:path";
5
4
  import { gunzipSync } from "node:zlib";
6
5
  import { canonicalJson, decodeBase64Url } from "./canonical.js";
@@ -107,21 +106,6 @@ const LICENSE_FILE_TOP_LEVEL_FIELDS = new Set([
107
106
  "tag",
108
107
  "signature",
109
108
  ]);
110
- const LICENSE_FILE_SECRET_FIELDS = new Set([
111
- "schemaVersion",
112
- "type",
113
- "audience",
114
- "licenseId",
115
- "fileId",
116
- "serviceUrl",
117
- "channel",
118
- "licenseKey",
119
- "secretVersion",
120
- "createdAt",
121
- "expiresAt",
122
- "nonce",
123
- "scope",
124
- ]);
125
109
  const USER_LICENSE_FILE_FORMAT = "slybrowser-license-compact-v1";
126
110
  const USER_LICENSE_FILE_FIELDS = new Set(["schemaVersion", "type", "format", "license"]);
127
111
  const USER_LICENSE_FILE_MAX_COMPRESSED_BYTES = 64 * 1024;
@@ -214,7 +198,7 @@ function trustedKeyMap(keys) {
214
198
  const entries = keys instanceof Map ? keys.entries() : Object.entries(keys ?? {});
215
199
  return new Map([...entries].map(([key, value]) => [key, Buffer.from(value)]));
216
200
  }
217
- function parseEncryptedLicenseFile(value, options) {
201
+ function validateEncryptedLicenseFile(value, options) {
218
202
  const document = requiredObject(unwrapUserLicenseFile(value));
219
203
  if (Object.keys(document).some((name) => !LICENSE_FILE_TOP_LEVEL_FIELDS.has(name)) ||
220
204
  Object.keys(document).length !== LICENSE_FILE_TOP_LEVEL_FIELDS.size ||
@@ -266,70 +250,16 @@ function parseEncryptedLicenseFile(value, options) {
266
250
  const verified = verifySignature(null, signedBody, createPublicKey({ key: Buffer.concat([ED25519_SPKI_PREFIX, publicKey]), format: "der", type: "spki" }), decodeBase64Url(requiredText(signature, "signature", 256), 64));
267
251
  if (!verified)
268
252
  fail("license_file_signature_invalid", "License file signature is invalid", 401);
269
- const passphrase = options.licenseFilePassphrase;
270
- if (typeof passphrase !== "string" || passphrase.length < 12) {
271
- fail("license_file_locked", "License file passphrase is missing or too short", 401);
272
- }
273
- const salt = decodeBase64Url(requiredText(kdf, "salt", 64), 16);
274
- if (salt.length !== 16)
275
- fail("authorization_invalid", "License file salt length is invalid");
276
- const nonce = decodeBase64Url(requiredText(encryption, "nonce", 64), 12);
277
- if (nonce.length !== 12)
278
- fail("authorization_invalid", "License file nonce length is invalid");
279
- const decipher = createDecipheriv("aes-256-gcm", scryptSync(passphrase, salt, 32, { N: 16_384, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }), nonce);
280
- decipher.setAAD(canonicalJson(publicLicenseFileHeader({ ...document, serviceUrl })));
281
- decipher.setAuthTag(decodeBase64Url(requiredText(document, "tag", 64), 16));
282
- let payload;
283
- try {
284
- const plaintext = Buffer.concat([
285
- decipher.update(decodeBase64Url(requiredText(document, "ciphertext", 64 * 1024), 64 * 1024)),
286
- decipher.final(),
287
- ]);
288
- payload = requiredObject(JSON.parse(plaintext.toString("utf8")));
289
- }
290
- catch (error) {
291
- if (error instanceof LicenseServiceError)
292
- throw error;
293
- fail("license_file_locked", "License file cannot be decrypted", 401);
294
- }
295
- if (Object.keys(payload).some((name) => !LICENSE_FILE_SECRET_FIELDS.has(name)) ||
296
- Object.keys(payload).length !== LICENSE_FILE_SECRET_FIELDS.size) {
297
- fail("license_file_payload_invalid", "License file payload contains unsupported claims");
298
- }
299
- const licenseKey = requiredText(payload, "licenseKey", 256);
300
- if (payload.schemaVersion !== 2 ||
301
- payload.type !== "slybrowser-license-secret" ||
302
- payload.audience !== document.audience ||
303
- payload.licenseId !== licenseId ||
304
- payload.fileId !== document.fileId ||
305
- normalizeServiceUrl(requiredText(payload, "serviceUrl"), options.allowInsecureLocalhost ?? false) !== serviceUrl ||
306
- payload.channel !== "stable" ||
307
- payload.secretVersion !== 1 ||
308
- payload.expiresAt !== document.expiresAt ||
309
- payload.scope !== expectedScope ||
310
- !/^sly_live_[0-9a-f-]{36}\.[A-Za-z0-9_-]{40,}$/.test(licenseKey) ||
311
- !licenseKey.startsWith(`sly_live_${licenseId}.`)) {
312
- fail("license_file_payload_invalid", "License file payload does not match its public header");
313
- }
314
- return { schemaVersion: 1, serviceUrl, licenseKey, channel: "stable" };
315
- }
316
- function localLicenseFileUnlockRequested(options) {
317
- return options.licenseFilePassphrase !== undefined;
253
+ return { serviceUrl, licenseId };
318
254
  }
319
255
  async function resolveEncryptedLicenseFileViaService(value, options) {
320
- const document = requiredObject(unwrapUserLicenseFile(value));
321
- if (document.schemaVersion !== 2 || document.type !== "slybrowser-license" ||
322
- document.audience !== "slybrowser-license-file" || document.channel !== "stable") {
323
- fail("authorization_invalid", "License file fields are invalid");
324
- }
325
- const documentServiceUrl = normalizeServiceUrl(requiredText(document, "serviceUrl"), options.allowInsecureLocalhost ?? false);
326
- assertTrustedAuthorizationOrigin(documentServiceUrl, options);
327
- const resolutionServiceUrl = normalizeServiceUrl(options.licenseFileResolutionServiceUrl ?? documentServiceUrl, options.allowInsecureLocalhost ?? false);
256
+ const { serviceUrl: documentServiceUrl, licenseId } = validateEncryptedLicenseFile(value, options);
328
257
  const fetchImplementation = options.fetch ?? fetch;
329
258
  let response;
330
259
  try {
331
- response = await fetchImplementation(`${resolutionServiceUrl}/v1/license-files/authorization`, {
260
+ response = await fetchImplementation(`${documentServiceUrl}/v1/license-files/authorization`, {
332
261
  method: "POST",
262
+ redirect: "error",
333
263
  headers: {
334
264
  "content-type": "application/json",
335
265
  "user-agent": "SlyBrowserNodeSDK/license-file",
@@ -362,205 +292,12 @@ async function resolveEncryptedLicenseFileViaService(value, options) {
362
292
  throw new LicenseServiceError(message, code, response.status, details);
363
293
  }
364
294
  const authorization = parseAuthorization(parsed, options.allowInsecureLocalhost ?? false);
365
- if (authorization.serviceUrl !== documentServiceUrl) {
366
- fail("license_service_invalid_response", "License-file authorization service URL does not match the license file", response.status);
295
+ if (authorization.serviceUrl !== documentServiceUrl ||
296
+ !authorization.licenseKey.startsWith(`sly_live_${licenseId}.`)) {
297
+ fail("license_service_invalid_response", "License-file authorization does not match the signed file", response.status);
367
298
  }
368
299
  return authorization;
369
300
  }
370
- const SEALED_LICENSE_TOP_LEVEL_FIELDS = new Set([
371
- "schemaVersion",
372
- "type",
373
- "audience",
374
- "serviceUrl",
375
- "channel",
376
- "sealedAt",
377
- "licenseKeySha256",
378
- "protection",
379
- "ciphertext",
380
- ]);
381
- function licenseKeySha256(licenseKey) {
382
- return createHash("sha256").update(licenseKey, "utf8").digest("hex");
383
- }
384
- function sealedLicenseEntropy(document) {
385
- return [
386
- "slybrowser-sealed-license-v1",
387
- requiredText(document, "serviceUrl", 2048),
388
- requiredText(document, "channel", 16),
389
- requiredText(document, "licenseKeySha256", 64),
390
- ].join("\0");
391
- }
392
- function assertTrustedAuthorizationOrigin(serviceUrl, options) {
393
- const trustedServiceUrls = (options.trustedServiceUrls ?? ["https://api.slybrowser.com"])
394
- .map((url) => normalizeServiceUrl(url, options.allowInsecureLocalhost ?? false));
395
- if (!trustedServiceUrls.includes(serviceUrl)) {
396
- fail("license_file_untrusted_origin", "License file service URL is not trusted", 403);
397
- }
398
- }
399
- async function windowsDpapi(operation, data, entropy) {
400
- if (process.platform !== "win32") {
401
- fail("sealed_license_unsupported", "Windows DPAPI sealed license files are only supported on Windows", 400);
402
- }
403
- const script = `
404
- $ErrorActionPreference = 'Stop'
405
- [System.Reflection.Assembly]::LoadWithPartialName('System.Security') | Out-Null
406
- $request = [Console]::In.ReadToEnd() | ConvertFrom-Json
407
- $data = [Convert]::FromBase64String([string]$request.data)
408
- $entropy = [Convert]::FromBase64String([string]$request.entropy)
409
- $scope = [System.Security.Cryptography.DataProtectionScope]::CurrentUser
410
- if ([string]$request.operation -eq 'protect') {
411
- $output = [System.Security.Cryptography.ProtectedData]::Protect($data, $entropy, $scope)
412
- } elseif ([string]$request.operation -eq 'unprotect') {
413
- $output = [System.Security.Cryptography.ProtectedData]::Unprotect($data, $entropy, $scope)
414
- } else {
415
- throw 'Unsupported DPAPI operation'
416
- }
417
- [Console]::Out.Write([Convert]::ToBase64String($output))
418
- `;
419
- const encodedCommand = Buffer.from(script, "utf16le").toString("base64");
420
- const request = JSON.stringify({
421
- operation,
422
- data: data.toString("base64"),
423
- entropy: Buffer.from(entropy, "utf8").toString("base64"),
424
- });
425
- return await new Promise((resolvePromise, reject) => {
426
- const child = spawn("powershell.exe", [
427
- "-NoLogo",
428
- "-NoProfile",
429
- "-NonInteractive",
430
- "-ExecutionPolicy",
431
- "Bypass",
432
- "-EncodedCommand",
433
- encodedCommand,
434
- ], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
435
- const stdout = [];
436
- const stderr = [];
437
- let settled = false;
438
- const rejectOnce = (error) => {
439
- if (settled)
440
- return;
441
- settled = true;
442
- reject(error);
443
- };
444
- child.stdout.on("data", (chunk) => {
445
- stdout.push(chunk);
446
- if (Buffer.concat(stdout).length > 16 * 1024) {
447
- child.kill();
448
- rejectOnce(new LicenseServiceError("Windows DPAPI returned an oversized response", "sealed_license_invalid", 400));
449
- }
450
- });
451
- child.stderr.on("data", (chunk) => {
452
- stderr.push(chunk);
453
- if (Buffer.concat(stderr).length > 8 * 1024)
454
- child.kill();
455
- });
456
- child.on("error", () => {
457
- rejectOnce(new LicenseServiceError("Windows DPAPI is unavailable", "sealed_license_unsupported", 400));
458
- });
459
- child.on("close", (code) => {
460
- if (settled)
461
- return;
462
- settled = true;
463
- if (code !== 0) {
464
- reject(new LicenseServiceError("Sealed license file cannot be decrypted on this Windows user or machine", "sealed_license_locked", 401));
465
- return;
466
- }
467
- try {
468
- resolvePromise(Buffer.from(Buffer.concat(stdout).toString("utf8").trim(), "base64"));
469
- }
470
- catch {
471
- reject(new LicenseServiceError("Windows DPAPI returned an invalid sealed license response", "sealed_license_invalid", 400));
472
- }
473
- });
474
- child.stdin.end(request);
475
- });
476
- }
477
- async function parseSealedLicenseFile(value, options) {
478
- const document = requiredObject(value);
479
- if (Object.keys(document).some((name) => !SEALED_LICENSE_TOP_LEVEL_FIELDS.has(name)) ||
480
- Object.keys(document).length !== SEALED_LICENSE_TOP_LEVEL_FIELDS.size ||
481
- document.schemaVersion !== 3 ||
482
- document.type !== "slybrowser-sealed-authorization" ||
483
- document.audience !== "slybrowser") {
484
- fail("sealed_license_invalid", "Sealed license file fields are invalid");
485
- }
486
- const serviceUrl = normalizeServiceUrl(requiredText(document, "serviceUrl"), options.allowInsecureLocalhost ?? false);
487
- assertTrustedAuthorizationOrigin(serviceUrl, options);
488
- if (requiredText(document, "channel", 16) !== "stable")
489
- fail("sealed_license_invalid", "Sealed license channel is invalid");
490
- const sealedAt = Date.parse(requiredText(document, "sealedAt", 64));
491
- if (!Number.isFinite(sealedAt))
492
- fail("sealed_license_invalid", "Sealed license timestamp is invalid");
493
- const sha256 = requiredText(document, "licenseKeySha256", 64);
494
- if (!/^[0-9a-f]{64}$/.test(sha256))
495
- fail("sealed_license_invalid", "Sealed license fingerprint is invalid");
496
- const protection = requiredObject(document.protection);
497
- if (Object.keys(protection).sort().join(",") !== "provider,scope" ||
498
- protection.provider !== "windows-dpapi" ||
499
- protection.scope !== "current-user") {
500
- fail("sealed_license_unsupported", "Sealed license protection is not supported", 400);
501
- }
502
- const ciphertext = decodeBase64Url(requiredText(document, "ciphertext", 64 * 1024), 64 * 1024);
503
- let parsed;
504
- try {
505
- const plaintext = await windowsDpapi("unprotect", ciphertext, sealedLicenseEntropy({ ...document, serviceUrl }));
506
- parsed = JSON.parse(plaintext.toString("utf8"));
507
- }
508
- catch (error) {
509
- if (error instanceof LicenseServiceError)
510
- throw error;
511
- fail("sealed_license_locked", "Sealed license file cannot be decrypted on this Windows user or machine", 401);
512
- }
513
- const authorization = parseAuthorization(parsed, options.allowInsecureLocalhost ?? false);
514
- if (authorization.serviceUrl !== serviceUrl || authorization.channel !== "stable" ||
515
- licenseKeySha256(authorization.licenseKey) !== sha256) {
516
- fail("sealed_license_mismatch", "Sealed license payload does not match its public header", 400);
517
- }
518
- return authorization;
519
- }
520
- export async function importLicenseFileToSealedAuthorization(inputPath, outputPath, options = {}) {
521
- const raw = await readFile(resolve(inputPath));
522
- if (raw.length > 64 * 1024)
523
- fail("authorization_invalid", "Authorization file is too large");
524
- let parsed;
525
- try {
526
- parsed = JSON.parse(raw.toString("utf8"));
527
- }
528
- catch {
529
- fail("authorization_invalid", "Authorization file is not valid JSON");
530
- }
531
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.schemaVersion !== 2) {
532
- fail("license_file_invalid", "Only encrypted v2 SlyBrowser license files can be imported", 400);
533
- }
534
- const authorization = parseEncryptedLicenseFile(parsed, options);
535
- assertTrustedAuthorizationOrigin(authorization.serviceUrl, options);
536
- const fingerprint = licenseKeySha256(authorization.licenseKey);
537
- const sealedAt = new Date().toISOString();
538
- const document = {
539
- schemaVersion: 3,
540
- type: "slybrowser-sealed-authorization",
541
- audience: "slybrowser",
542
- serviceUrl: authorization.serviceUrl,
543
- channel: authorization.channel,
544
- sealedAt,
545
- licenseKeySha256: fingerprint,
546
- protection: {
547
- provider: "windows-dpapi",
548
- scope: "current-user",
549
- },
550
- ciphertext: "",
551
- };
552
- const ciphertext = await windowsDpapi("protect", Buffer.from(JSON.stringify(authorization), "utf8"), sealedLicenseEntropy(document));
553
- document.ciphertext = ciphertext.toString("base64url");
554
- const output = resolve(outputPath);
555
- await writeFile(output, `${JSON.stringify(document, null, 2)}\n`, { flag: "wx", mode: 0o600 });
556
- return {
557
- output,
558
- serviceUrl: authorization.serviceUrl,
559
- channel: "stable",
560
- protection: "windows-dpapi-current-user",
561
- licenseKeySha256: fingerprint,
562
- };
563
- }
564
301
  export async function readLicenseAuthorization(path, options = {}) {
565
302
  const raw = await readFile(resolve(path));
566
303
  if (raw.length > 64 * 1024)
@@ -578,14 +315,7 @@ export async function readLicenseAuthorization(path, options = {}) {
578
315
  : parsed;
579
316
  if (document && typeof document === "object" && !Array.isArray(document) &&
580
317
  document.schemaVersion === 2) {
581
- if (!localLicenseFileUnlockRequested(options)) {
582
- return await resolveEncryptedLicenseFileViaService(parsed, options);
583
- }
584
- return parseEncryptedLicenseFile(parsed, options);
585
- }
586
- if (document && typeof document === "object" && !Array.isArray(document) &&
587
- document.schemaVersion === 3) {
588
- return await parseSealedLicenseFile(parsed, options);
318
+ return await resolveEncryptedLicenseFileViaService(parsed, options);
589
319
  }
590
320
  return parseAuthorization(parsed, options.allowInsecureLocalhost ?? false);
591
321
  }
@@ -708,7 +438,7 @@ export class LicenseServiceClient {
708
438
  async licenseInfo(options = {}) {
709
439
  const platform = options.platform ?? currentPlatform();
710
440
  const arch = options.arch ?? currentArch();
711
- const sdkVersion = options.sdkVersion ?? "0.2.0";
441
+ const sdkVersion = options.sdkVersion ?? "0.3.0";
712
442
  const versionPolicy = options.versionPolicy ?? (options.browserVersion === undefined ? "latest" : "exact");
713
443
  if (versionPolicy === "latest" && options.browserVersion !== undefined) {
714
444
  fail("version_policy_invalid", "Latest selection cannot include a requested browser version");
@@ -814,7 +544,7 @@ export class LicenseServiceClient {
814
544
  async createSession(options = {}) {
815
545
  const platform = options.platform ?? currentPlatform();
816
546
  const arch = options.arch ?? currentArch();
817
- const sdkVersion = options.sdkVersion ?? "0.2.0";
547
+ const sdkVersion = options.sdkVersion ?? "0.3.0";
818
548
  const versionPolicy = options.versionPolicy ?? (options.browserVersion === undefined ? "latest" : "exact");
819
549
  if (versionPolicy === "latest" && options.browserVersion !== undefined) {
820
550
  fail("version_policy_invalid", "Latest selection cannot include a requested browser version");
@@ -940,7 +670,7 @@ export class LicenseServiceClient {
940
670
  async createRuntimeSession(options = {}) {
941
671
  const platform = options.platform ?? currentPlatform();
942
672
  const arch = options.arch ?? currentArch();
943
- const sdkVersion = options.sdkVersion ?? "0.2.0";
673
+ const sdkVersion = options.sdkVersion ?? "0.3.0";
944
674
  const startupId = options.startupId ?? newStartupId();
945
675
  if (!/^st_[A-Za-z0-9_-]{16,120}$/.test(startupId)) {
946
676
  fail("startup_id_invalid", "Runtime startup ID is invalid");
@@ -1170,18 +900,32 @@ export class LicenseServiceClient {
1170
900
  authorization: `Bootstrap ${grant.bootstrapToken}`,
1171
901
  });
1172
902
  }
1173
- async downloadArtifact(grant) {
1174
- const response = await this.#fetch(grant.artifact.url, {
903
+ async downloadArtifact(grant, signal) {
904
+ let artifactUrl;
905
+ try {
906
+ artifactUrl = new URL(grant.artifact.url);
907
+ }
908
+ catch {
909
+ fail("license_service_invalid_response", "Artifact URL is invalid");
910
+ }
911
+ const response = await this.#fetch(artifactUrl.toString(), {
1175
912
  method: "GET",
1176
913
  headers: { authorization: `Session ${grant.sessionToken}` },
1177
914
  redirect: "error",
915
+ ...(signal === undefined ? {} : { signal }),
1178
916
  });
1179
917
  if (!response.ok || !response.body)
1180
918
  await this.#throwResponse(response);
1181
919
  return response;
1182
920
  }
1183
- async downloadRuntimeArtifact(grant) {
1184
- const artifactUrl = new URL(grant.downloadTicket.artifactUrl);
921
+ async downloadRuntimeArtifact(grant, signal) {
922
+ let artifactUrl;
923
+ try {
924
+ artifactUrl = new URL(grant.downloadTicket.artifactUrl);
925
+ }
926
+ catch {
927
+ fail("license_service_invalid_response", "Runtime artifact URL is invalid");
928
+ }
1185
929
  if (artifactUrl.pathname.startsWith("/v1/releases/artifacts/")) {
1186
930
  artifactUrl.pathname = artifactUrl.pathname.replace("/v1/releases/artifacts/", "/v2/runtime/artifacts/");
1187
931
  }
@@ -1192,6 +936,7 @@ export class LicenseServiceClient {
1192
936
  method: "GET",
1193
937
  headers: { authorization: `Download ${grant.downloadTicket.token}` },
1194
938
  redirect: "error",
939
+ ...(signal === undefined ? {} : { signal }),
1195
940
  });
1196
941
  if (!response.ok || !response.body)
1197
942
  await this.#throwResponse(response);