sealcode 1.2.0 → 1.3.1
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/package.json +1 -1
- package/src/cli-grants.js +66 -8
- package/src/cli-watch.js +168 -3
- package/src/cli.js +63 -4
- package/src/device.js +3 -0
package/package.json
CHANGED
package/src/cli-grants.js
CHANGED
|
@@ -354,20 +354,24 @@ async function runPause({ grantId, reason, extendOnResume = false }) {
|
|
|
354
354
|
/**
|
|
355
355
|
* sealcode@1.2.0 — Resume a paused access code from the CLI.
|
|
356
356
|
*
|
|
357
|
-
* sealcode resume <grantId>
|
|
357
|
+
* sealcode resume <grantId> [--unlock]
|
|
358
358
|
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
361
|
-
*
|
|
359
|
+
* sealcode@1.3.0:
|
|
360
|
+
* --unlock pushes an unlock event to the recipient's live watcher,
|
|
361
|
+
* which re-materializes plaintext using the K it kept cached through
|
|
362
|
+
* the pause. Without --unlock, status flips back to active but the
|
|
363
|
+
* recipient stays locked until they manually `sealcode unlock`
|
|
364
|
+
* (which now works without a passphrase since the session was
|
|
365
|
+
* preserved through the pause).
|
|
362
366
|
*/
|
|
363
|
-
async function runResume({ grantId }) {
|
|
364
|
-
if (!grantId) throw new Error('Usage: sealcode resume <grantId>');
|
|
367
|
+
async function runResume({ grantId, autoUnlock = false }) {
|
|
368
|
+
if (!grantId) throw new Error('Usage: sealcode resume <grantId> [--unlock]');
|
|
365
369
|
let res;
|
|
366
370
|
try {
|
|
367
371
|
res = await request(
|
|
368
372
|
'POST',
|
|
369
373
|
`/api/v1/grants/${encodeURIComponent(grantId)}/resume`,
|
|
370
|
-
{ auth: true, body: {} },
|
|
374
|
+
{ auth: true, body: { autoUnlock: !!autoUnlock } },
|
|
371
375
|
);
|
|
372
376
|
} catch (err) {
|
|
373
377
|
if (err instanceof ApiError) {
|
|
@@ -391,7 +395,53 @@ async function runResume({ grantId }) {
|
|
|
391
395
|
const mins = Math.round((res.pausedDurationMs || 0) / 60000);
|
|
392
396
|
process.stdout.write(` expiry shifted forward by ~${mins}m (paused duration)\n`);
|
|
393
397
|
}
|
|
394
|
-
|
|
398
|
+
if (autoUnlock) {
|
|
399
|
+
process.stdout.write(' Recipient\'s 1.3+ watcher will auto-unlock within ~1 second.\n');
|
|
400
|
+
process.stdout.write(' (No redeem needed — K was kept cached through the pause.)\n');
|
|
401
|
+
} else {
|
|
402
|
+
process.stdout.write(' Recipient can run `sealcode unlock` to re-open (no passphrase needed).\n');
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* sealcode@1.3.0 — Ask the server whether a grant-derived session is
|
|
408
|
+
* currently allowed to unlock. Used by the `unlock` command to refuse
|
|
409
|
+
* unlocking while the grant is paused (or revoked / expired) WITHOUT
|
|
410
|
+
* requiring the user to redeem first.
|
|
411
|
+
*
|
|
412
|
+
* Returns one of:
|
|
413
|
+
* { allow: true, reason: 'active', ... }
|
|
414
|
+
* { allow: false, reason: 'paused' | 'revoked' | 'expired' | 'not_found', ... }
|
|
415
|
+
* { allow: true, reason: 'network_unreachable' } (best-effort fallback)
|
|
416
|
+
*
|
|
417
|
+
* The last case lets a recipient who's offline still unlock — by
|
|
418
|
+
* design. If you want strict "must be online to unlock", pair this
|
|
419
|
+
* with the `strictMode` grant flag and the watcher's offline-grace
|
|
420
|
+
* (which already covers that case).
|
|
421
|
+
*/
|
|
422
|
+
async function checkUnlockAllowed(code, { strict = false } = {}) {
|
|
423
|
+
if (!code) return { allow: true, reason: 'no_code_local_only' };
|
|
424
|
+
try {
|
|
425
|
+
const res = await request('POST', '/api/v1/access/unlock-check', {
|
|
426
|
+
body: {
|
|
427
|
+
code: String(code).trim(),
|
|
428
|
+
deviceFingerprint: getDeviceFingerprint(),
|
|
429
|
+
},
|
|
430
|
+
timeoutMs: 8000,
|
|
431
|
+
});
|
|
432
|
+
return res || { allow: false, reason: 'unknown' };
|
|
433
|
+
} catch (err) {
|
|
434
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
435
|
+
// Older deploys (pre-1.3) without the endpoint — degrade open
|
|
436
|
+
// so existing CLIs against an older server don't break.
|
|
437
|
+
return { allow: true, reason: 'endpoint_missing' };
|
|
438
|
+
}
|
|
439
|
+
if (err instanceof ApiError && (err.status === 0 || err.status >= 500)) {
|
|
440
|
+
if (strict) return { allow: false, reason: 'network_unreachable_strict' };
|
|
441
|
+
return { allow: true, reason: 'network_unreachable' };
|
|
442
|
+
}
|
|
443
|
+
throw err;
|
|
444
|
+
}
|
|
395
445
|
}
|
|
396
446
|
|
|
397
447
|
async function runRevoke({ grantId }) {
|
|
@@ -481,6 +531,13 @@ async function runRedeem({ projectRoot, code, json = false, agreeNda = false })
|
|
|
481
531
|
source: 'grant',
|
|
482
532
|
grantId: g.id,
|
|
483
533
|
grantCodeHash: require('crypto').createHash('sha256').update(trimmed).digest('hex'),
|
|
534
|
+
// sealcode@1.3.0 — stash the code itself so the local
|
|
535
|
+
// unlock command can ask the server "may I unlock?" after
|
|
536
|
+
// a pause without requiring the user to re-type it. Lives
|
|
537
|
+
// under ~/.sealcode/sessions/ at mode 0600 alongside the
|
|
538
|
+
// already-cached K, so this doesn't materially expand the
|
|
539
|
+
// local attack surface.
|
|
540
|
+
grantCode: trimmed,
|
|
484
541
|
grantExpiresAt: g.expiresAt,
|
|
485
542
|
deviceFingerprint: getDeviceFingerprint(),
|
|
486
543
|
strictWatch: !!g.strictMode,
|
|
@@ -667,4 +724,5 @@ module.exports = {
|
|
|
667
724
|
runLockdown,
|
|
668
725
|
runPause,
|
|
669
726
|
runResume,
|
|
727
|
+
checkUnlockAllowed,
|
|
670
728
|
};
|
package/src/cli-watch.js
CHANGED
|
@@ -55,6 +55,7 @@ const {
|
|
|
55
55
|
projectId,
|
|
56
56
|
} = require('./keystore');
|
|
57
57
|
const { runLock } = require('./seal');
|
|
58
|
+
const { runUnlock } = require('./open');
|
|
58
59
|
const { getDeviceFingerprint } = require('./device');
|
|
59
60
|
const { SealcodeError } = require('./errors');
|
|
60
61
|
const grantPolicy = require('./grant-policy');
|
|
@@ -313,6 +314,7 @@ function startExfilWatchers({
|
|
|
313
314
|
encoding: 'utf8',
|
|
314
315
|
timeout: 1500,
|
|
315
316
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
317
|
+
windowsHide: true,
|
|
316
318
|
})
|
|
317
319
|
.trim();
|
|
318
320
|
} catch (_) { /* repo may not have git */ }
|
|
@@ -416,6 +418,7 @@ function startExfilWatchers({
|
|
|
416
418
|
const cur = cp
|
|
417
419
|
.execFileSync('git', ['-C', projectAbs, 'remote', '-v'], {
|
|
418
420
|
encoding: 'utf8', timeout: 1500, stdio: ['ignore', 'pipe', 'ignore'],
|
|
421
|
+
windowsHide: true,
|
|
419
422
|
})
|
|
420
423
|
.trim();
|
|
421
424
|
if (initialRemotes && cur !== initialRemotes) {
|
|
@@ -529,10 +532,22 @@ async function runWatch({
|
|
|
529
532
|
strictWatch: strict,
|
|
530
533
|
});
|
|
531
534
|
|
|
535
|
+
// sealcode@1.3.0 — track local pause state. When true, files are
|
|
536
|
+
// locked-on-disk but K is still in our session cache. We keep
|
|
537
|
+
// heartbeating so a server-side "unlock" event (owner resumed with
|
|
538
|
+
// auto-unlock) can re-materialize plaintext without a redeem.
|
|
539
|
+
let pausedLocally = false;
|
|
540
|
+
|
|
532
541
|
// If we somehow started watching an already-locked grant, lock first
|
|
533
|
-
// and exit
|
|
542
|
+
// and exit (revoke/expire/etc), OR soft-lock and stay running (paused).
|
|
534
543
|
if (first.response.action === 'lock') {
|
|
535
|
-
|
|
544
|
+
if (first.response.keepSession || first.response.reason === 'paused') {
|
|
545
|
+
await softLock(projectRoot, config, trimmedCode, first.response.reason || 'paused', json, daemon);
|
|
546
|
+
pausedLocally = true;
|
|
547
|
+
writeWatchState(projectRoot, { pausedLocally: true, pausedReason: first.response.reason || 'paused' });
|
|
548
|
+
} else {
|
|
549
|
+
return finalLock(projectRoot, config, trimmedCode, first.response.reason, json, daemon);
|
|
550
|
+
}
|
|
536
551
|
}
|
|
537
552
|
|
|
538
553
|
if (daemon) {
|
|
@@ -695,9 +710,76 @@ async function runWatch({
|
|
|
695
710
|
continue;
|
|
696
711
|
}
|
|
697
712
|
consecutiveTransient = 0;
|
|
713
|
+
|
|
714
|
+
// sealcode@1.3.0 — branch on action.
|
|
698
715
|
if (r.response.action === 'lock') {
|
|
716
|
+
// Soft lock (paused): re-encrypt, KEEP K, KEEP watcher alive.
|
|
717
|
+
// The 1.3 server marks this with `keepSession: true`; we also
|
|
718
|
+
// honor the legacy `reason: "paused"` from 1.2.0 servers in case
|
|
719
|
+
// someone is on a mixed deploy.
|
|
720
|
+
const isSoft = !!r.response.keepSession || r.response.reason === 'paused';
|
|
721
|
+
if (isSoft) {
|
|
722
|
+
if (!pausedLocally) {
|
|
723
|
+
await softLock(projectRoot, config, trimmedCode, r.response.reason || 'paused', json, daemon);
|
|
724
|
+
pausedLocally = true;
|
|
725
|
+
writeWatchState(projectRoot, { pausedLocally: true, pausedReason: r.response.reason || 'paused' });
|
|
726
|
+
appendLog(projectRoot, { type: 'soft_locked', reason: r.response.reason || 'paused' });
|
|
727
|
+
}
|
|
728
|
+
// Stay in the loop — wait for the next event (unlock or hard lock).
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
// Hard lock — revoke / expire / device mismatch / idle / etc.
|
|
699
732
|
return finalLock(projectRoot, config, trimmedCode, r.response.reason, json, daemon);
|
|
700
733
|
}
|
|
734
|
+
|
|
735
|
+
if (r.response.action === 'unlock') {
|
|
736
|
+
// Owner resumed with auto-unlock. Re-materialize plaintext using
|
|
737
|
+
// the K we kept cached through the pause.
|
|
738
|
+
try {
|
|
739
|
+
const K = loadSession(projectRoot);
|
|
740
|
+
if (!K) {
|
|
741
|
+
// We somehow lost K (e.g. another process cleared it). Best
|
|
742
|
+
// we can do: stay locked and nudge the user.
|
|
743
|
+
appendLog(projectRoot, { type: 'unlock_skipped_no_key' });
|
|
744
|
+
if (!daemon && !json) ui.warn(`[${ts()}] resumed, but no cached key on disk — run: sealcode redeem ${trimmedCode}`);
|
|
745
|
+
} else {
|
|
746
|
+
const sm = loadSessionMeta(projectRoot);
|
|
747
|
+
const pol = (sm && sm.meta && sm.meta.policy) || {};
|
|
748
|
+
const wctx = pol.watermark ? {
|
|
749
|
+
email: (sm && sm.meta && sm.meta.recipientEmail) || '',
|
|
750
|
+
grantId: (sm && sm.meta && sm.meta.grantId) || '',
|
|
751
|
+
projectName: (sm && sm.meta && sm.meta.projectName) || '',
|
|
752
|
+
date: new Date().toISOString().slice(0, 10),
|
|
753
|
+
fingerprint: getDeviceFingerprint(),
|
|
754
|
+
} : null;
|
|
755
|
+
const ures = await runUnlock({
|
|
756
|
+
projectRoot, config, K, removeStubs: true, policy: pol, watermarkCtx: wctx,
|
|
757
|
+
log: () => {},
|
|
758
|
+
});
|
|
759
|
+
K.fill(0);
|
|
760
|
+
appendLog(projectRoot, { type: 'auto_unlocked', count: ures.count, reason: r.response.reason || 'resumed' });
|
|
761
|
+
if (!daemon && !json) ui.ok(`[${ts()}] owner resumed — auto-unlocked ${ui.c.bold(ures.count)} files`);
|
|
762
|
+
}
|
|
763
|
+
} catch (err) {
|
|
764
|
+
appendLog(projectRoot, { type: 'auto_unlock_error', error: String(err.message || err) });
|
|
765
|
+
if (!daemon && !json) ui.warn(`[${ts()}] auto-unlock failed: ${err.message || err}`);
|
|
766
|
+
}
|
|
767
|
+
pausedLocally = false;
|
|
768
|
+
writeWatchState(projectRoot, { pausedLocally: false, pausedReason: null });
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Plain "ok" or "warn". If we WERE paused and the server now says
|
|
773
|
+
// "ok" without sending an unlock event, that means the owner
|
|
774
|
+
// resumed without auto-unlock. Clear our paused flag so the user's
|
|
775
|
+
// `sealcode unlock` invocation will be allowed by the local guard;
|
|
776
|
+
// we leave files locked-on-disk and let them choose when to unlock.
|
|
777
|
+
if (pausedLocally && (r.response.action === 'ok' || r.response.action === 'warn')) {
|
|
778
|
+
pausedLocally = false;
|
|
779
|
+
writeWatchState(projectRoot, { pausedLocally: false, pausedReason: null });
|
|
780
|
+
appendLog(projectRoot, { type: 'resumed_manual' });
|
|
781
|
+
if (!daemon && !json) ui.ok(`[${ts()}] owner resumed — files stay locked until you run: ${ui.c.cyan('sealcode unlock')}`);
|
|
782
|
+
}
|
|
701
783
|
printTick(r.response, json, verbose, daemon, projectRoot);
|
|
702
784
|
// After a successful (possibly long-polled) heartbeat we do NOT
|
|
703
785
|
// sleep here — long-poll already consumed our budget. If the server
|
|
@@ -731,6 +813,61 @@ function printTick(resp, json, verbose, daemon, projectRoot) {
|
|
|
731
813
|
}
|
|
732
814
|
}
|
|
733
815
|
|
|
816
|
+
/**
|
|
817
|
+
* sealcode@1.3.0 — Soft lock: re-encrypt plaintext but KEEP the cached
|
|
818
|
+
* K and KEEP the watcher process alive. Used for pause, which is
|
|
819
|
+
* meant to be reversible — the owner can resume and (optionally)
|
|
820
|
+
* push an `action: "unlock"` event that re-materializes the plaintext
|
|
821
|
+
* without requiring the recipient to redeem again.
|
|
822
|
+
*
|
|
823
|
+
* Idempotent: if there's no K cached (e.g. we restarted into an
|
|
824
|
+
* already-locked project), this is a no-op.
|
|
825
|
+
*/
|
|
826
|
+
async function softLock(projectRoot, config, code, reason, json, daemon) {
|
|
827
|
+
if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
|
|
828
|
+
const label = reason || 'paused';
|
|
829
|
+
if (daemon) {
|
|
830
|
+
appendLog(projectRoot, { type: 'soft_lock', reason: label });
|
|
831
|
+
} else if (json) {
|
|
832
|
+
process.stdout.write(JSON.stringify({ type: 'soft_lock', reason: label }) + '\n');
|
|
833
|
+
} else {
|
|
834
|
+
ui.warn(`[${ts()}] paused by owner — re-locking, keeping session for resume`);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
const K = loadSession(projectRoot);
|
|
838
|
+
if (!K) {
|
|
839
|
+
// Nothing to re-encrypt. Files are already in their locked state.
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
const _sessionMeta = loadSessionMeta(projectRoot);
|
|
844
|
+
const _preserveUnseen =
|
|
845
|
+
!!_sessionMeta &&
|
|
846
|
+
_sessionMeta.meta &&
|
|
847
|
+
_sessionMeta.meta.source === 'grant' &&
|
|
848
|
+
_sessionMeta.meta.policy &&
|
|
849
|
+
Array.isArray(_sessionMeta.meta.policy.allowedPaths) &&
|
|
850
|
+
_sessionMeta.meta.policy.allowedPaths.length > 0;
|
|
851
|
+
|
|
852
|
+
try {
|
|
853
|
+
const res = await runLock({ projectRoot, config, K, preserveUnseen: _preserveUnseen });
|
|
854
|
+
K.fill(0);
|
|
855
|
+
if (daemon) {
|
|
856
|
+
appendLog(projectRoot, { type: 'soft_locked', count: res.count, reason: label });
|
|
857
|
+
} else if (!json) {
|
|
858
|
+
ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files — waiting for owner to resume`);
|
|
859
|
+
ui.hint(` Files will auto-unlock if the owner picks "Resume + unlock now",`);
|
|
860
|
+
ui.hint(` or run ${ui.c.cyan('sealcode unlock')} yourself once they resume (no passphrase).`);
|
|
861
|
+
}
|
|
862
|
+
} catch (err) {
|
|
863
|
+
// If soft-lock fails we DO NOT escalate to hard-lock — that would
|
|
864
|
+
// wipe the session and force a redeem, which is exactly the UX we
|
|
865
|
+
// built this feature to avoid. Surface the error and stay locked.
|
|
866
|
+
if (daemon) appendLog(projectRoot, { type: 'soft_lock_error', error: String(err.message || err) });
|
|
867
|
+
else if (!json) ui.fail(`soft-lock failed: ${err.message}. Files may still contain plaintext — investigate.`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
734
871
|
async function finalLock(projectRoot, config, code, reason, json, daemon) {
|
|
735
872
|
if (!json && !daemon && ui.STDERR_TTY) process.stderr.write('\r\x1b[K');
|
|
736
873
|
const label = reason || 'lock';
|
|
@@ -781,7 +918,8 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
|
|
|
781
918
|
} else {
|
|
782
919
|
ui.ok(`[${ts()}] re-locked ${ui.c.bold(res.count)} files into ${ui.c.cyan(config.lockedDir + '/')} — session cleared`);
|
|
783
920
|
// sealcode@1.2.0 — be specific about the "paused" case so the
|
|
784
|
-
// recipient knows it's reversible
|
|
921
|
+
// recipient knows it's reversible. (sealcode@1.3.0+ goes through
|
|
922
|
+
// softLock instead and won't reach this branch.)
|
|
785
923
|
if (label === 'paused') {
|
|
786
924
|
ui.hint(' This access code was PAUSED by the owner — not revoked.');
|
|
787
925
|
ui.hint(` Wait for the owner to resume, then run: ${ui.c.cyan(`sealcode redeem ${code}`)}`);
|
|
@@ -796,9 +934,33 @@ async function finalLock(projectRoot, config, code, reason, json, daemon) {
|
|
|
796
934
|
* Helper: spawn a detached `sealcode watch <code> --daemon` child. Used
|
|
797
935
|
* by runUnlock when the unlock came from a grant-derived session.
|
|
798
936
|
* Returns the child pid (already detached). Best-effort.
|
|
937
|
+
*
|
|
938
|
+
* Windows specifics (sealcode@1.3.1):
|
|
939
|
+
* - `windowsHide: true` keeps the spawned process from popping a
|
|
940
|
+
* console window. Without this, every daemon spawn flashes a black
|
|
941
|
+
* cmd window which users (rightly) find alarming.
|
|
942
|
+
* - We also guard against re-spawn loops: if a daemon is already
|
|
943
|
+
* alive for this project we just return its pid instead of
|
|
944
|
+
* spawning a new one. Without this guard, repeated `redeem` calls
|
|
945
|
+
* (or any startup hook that re-runs redeem) accumulate orphan
|
|
946
|
+
* daemons that each show up as a pop-and-vanish window when they
|
|
947
|
+
* periodically run their child-process sweeps.
|
|
799
948
|
*/
|
|
800
949
|
function spawnDaemonWatcher({ projectRoot, code }) {
|
|
801
950
|
try {
|
|
951
|
+
// If a healthy daemon is already running for this project, don't
|
|
952
|
+
// start another one. This is the single most important defense
|
|
953
|
+
// against the "windows pop in and out" loop on Windows.
|
|
954
|
+
const existing = readWatcherStatus(projectRoot);
|
|
955
|
+
if (existing.state === 'alive') {
|
|
956
|
+
return { pid: existing.pid, reused: true };
|
|
957
|
+
}
|
|
958
|
+
// Stale / dead state file should be cleared before respawn, otherwise
|
|
959
|
+
// the new daemon may collide with leftover bookkeeping.
|
|
960
|
+
if (existing.state === 'stale' || existing.state === 'dead') {
|
|
961
|
+
try { fs.unlinkSync(watchStateFile(projectRoot)); } catch (_) { /* ignore */ }
|
|
962
|
+
}
|
|
963
|
+
|
|
802
964
|
ensureDir(WATCH_LOG_DIR);
|
|
803
965
|
const bin = process.execPath;
|
|
804
966
|
const entry = require.resolve('../bin/sealcode.js');
|
|
@@ -809,6 +971,9 @@ function spawnDaemonWatcher({ projectRoot, code }) {
|
|
|
809
971
|
cwd: projectRoot,
|
|
810
972
|
detached: true,
|
|
811
973
|
stdio: 'ignore',
|
|
974
|
+
// Critical on Windows — without this, the spawned daemon
|
|
975
|
+
// opens a visible cmd window. macOS / Linux ignore the flag.
|
|
976
|
+
windowsHide: true,
|
|
812
977
|
env: { ...process.env, SEALCODE_AUTO_DAEMON: '1' },
|
|
813
978
|
},
|
|
814
979
|
);
|
package/src/cli.js
CHANGED
|
@@ -40,6 +40,7 @@ const {
|
|
|
40
40
|
runRedeem,
|
|
41
41
|
runPause,
|
|
42
42
|
runResume,
|
|
43
|
+
checkUnlockAllowed,
|
|
43
44
|
} = require('./cli-grants');
|
|
44
45
|
const {
|
|
45
46
|
runCiCreate,
|
|
@@ -286,6 +287,63 @@ function build() {
|
|
|
286
287
|
try {
|
|
287
288
|
const projectRoot = resolveProject(program.opts());
|
|
288
289
|
const config = getActiveConfig(projectRoot);
|
|
290
|
+
|
|
291
|
+
// sealcode@1.3.0 — grant-derived sessions must clear a server
|
|
292
|
+
// status check before unlocking. This is what stops a recipient
|
|
293
|
+
// from running `sealcode unlock` to bypass a pause.
|
|
294
|
+
//
|
|
295
|
+
// We do this BEFORE resolveKey so a paused/revoked grant
|
|
296
|
+
// doesn't even decrypt the cached K from disk. If the local
|
|
297
|
+
// session isn't grant-derived (owner-passphrase flow), this
|
|
298
|
+
// whole block is a no-op.
|
|
299
|
+
{
|
|
300
|
+
const { loadSessionMeta: _lsm } = require('./keystore');
|
|
301
|
+
const _sm = _lsm(projectRoot);
|
|
302
|
+
if (_sm && _sm.meta && _sm.meta.source === 'grant' && _sm.meta.grantCode) {
|
|
303
|
+
const _strict = !!_sm.meta.strictWatch;
|
|
304
|
+
const _gate = await checkUnlockAllowed(_sm.meta.grantCode, { strict: _strict });
|
|
305
|
+
if (!_gate.allow) {
|
|
306
|
+
if (_gate.reason === 'paused') {
|
|
307
|
+
ui.fail(`This access has been PAUSED by the project owner.`);
|
|
308
|
+
if (_gate.pauseReason) ui.hint(` Reason: ${_gate.pauseReason}`);
|
|
309
|
+
ui.hint(` Files stay locked until the owner resumes. You'll get an automatic`);
|
|
310
|
+
ui.hint(` notification (and auto-unlock) when they do — no redeem needed.`);
|
|
311
|
+
process.exitCode = 1;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (_gate.reason === 'revoked') {
|
|
315
|
+
ui.fail('This access code has been revoked. You can no longer unlock.');
|
|
316
|
+
ui.hint(' Ask the owner for a fresh code: `sealcode share` on their side.');
|
|
317
|
+
process.exitCode = 1;
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (_gate.reason === 'expired') {
|
|
321
|
+
ui.fail('This access code has expired. You can no longer unlock.');
|
|
322
|
+
ui.hint(' Ask the owner for a fresh code.');
|
|
323
|
+
process.exitCode = 1;
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (_gate.reason === 'not_found') {
|
|
327
|
+
ui.fail('Access code not found on the server (it may have been deleted).');
|
|
328
|
+
process.exitCode = 1;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (_gate.reason === 'network_unreachable_strict') {
|
|
332
|
+
ui.fail('Cannot reach sealcode.dev to verify access status, and this grant is strict.');
|
|
333
|
+
ui.hint(' Reconnect to the network, then re-run `sealcode unlock`.');
|
|
334
|
+
process.exitCode = 1;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
ui.fail(`Unlock denied (${_gate.reason}).`);
|
|
338
|
+
process.exitCode = 1;
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (_gate.reason === 'network_unreachable') {
|
|
342
|
+
ui.warn(`Could not reach sealcode.dev — proceeding with cached key (lenient grant).`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
289
347
|
const K = await resolveKey(projectRoot, config, { useRecovery: opts.recovery });
|
|
290
348
|
ui.step(`unlocking ${ui.c.dim(projectRoot)}`);
|
|
291
349
|
const sp = new ui.Spinner('decrypting').start();
|
|
@@ -774,14 +832,15 @@ function build() {
|
|
|
774
832
|
}
|
|
775
833
|
});
|
|
776
834
|
|
|
777
|
-
// -------- resume (sealcode@1.2.0) --------
|
|
835
|
+
// -------- resume (sealcode@1.2.0; --unlock added 1.3.0) --------
|
|
778
836
|
program
|
|
779
837
|
.command('resume')
|
|
780
838
|
.argument('<grantId>', 'grant ID to resume (see `sealcode grants`)')
|
|
781
|
-
.description('Resume a paused access code. The recipient
|
|
782
|
-
.
|
|
839
|
+
.description('Resume a paused access code. The recipient\'s 1.3+ watcher can re-open the working copy automatically — no redeem needed.')
|
|
840
|
+
.option('--unlock', 'also push an unlock event so the recipient\'s files re-materialize automatically (1.3+ watcher required)', false)
|
|
841
|
+
.action(async (grantId, opts) => {
|
|
783
842
|
try {
|
|
784
|
-
await runResume({ grantId });
|
|
843
|
+
await runResume({ grantId, autoUnlock: !!opts.unlock });
|
|
785
844
|
} catch (err) {
|
|
786
845
|
process.exitCode = reportError(err);
|
|
787
846
|
}
|
package/src/device.js
CHANGED
|
@@ -72,6 +72,9 @@ function readMachineId() {
|
|
|
72
72
|
encoding: 'utf8',
|
|
73
73
|
timeout: 1500,
|
|
74
74
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
75
|
+
// Stop reg.exe from flashing a console window — happens on every
|
|
76
|
+
// command run because device.js is loaded by `clientInfo()`.
|
|
77
|
+
windowsHide: true,
|
|
75
78
|
});
|
|
76
79
|
const m = /MachineGuid\s+REG_SZ\s+([0-9a-f-]+)/i.exec(out);
|
|
77
80
|
return m ? m[1] : null;
|