@skrr-ai/auth-core 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -0
- package/dist/cjs/aead.d.ts +24 -0
- package/dist/cjs/aead.js +65 -0
- package/dist/cjs/authExpiredDetector.d.ts +65 -0
- package/dist/cjs/authExpiredDetector.js +89 -0
- package/dist/cjs/authHelper.d.ts +51 -0
- package/dist/cjs/authHelper.js +321 -0
- package/dist/cjs/credentialEnvelope.d.ts +85 -0
- package/dist/cjs/credentialEnvelope.js +230 -0
- package/dist/cjs/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/cjs/credentialEnvelopeBridge.js +637 -0
- package/dist/cjs/credentialResolver.d.ts +278 -0
- package/dist/cjs/credentialResolver.js +423 -0
- package/dist/cjs/daemonPair.d.ts +172 -0
- package/dist/cjs/daemonPair.js +495 -0
- package/dist/cjs/daemonScopes.d.ts +74 -0
- package/dist/cjs/daemonScopes.js +84 -0
- package/dist/cjs/deviceIdentityBridge.d.ts +113 -0
- package/dist/cjs/deviceIdentityBridge.js +591 -0
- package/dist/cjs/deviceKey.d.ts +167 -0
- package/dist/cjs/deviceKey.js +332 -0
- package/dist/cjs/fdToken.d.ts +17 -0
- package/dist/cjs/fdToken.js +238 -0
- package/dist/cjs/harnessTrust.d.ts +72 -0
- package/dist/cjs/harnessTrust.js +103 -0
- package/dist/cjs/headers-helper.d.ts +25 -0
- package/dist/cjs/headers-helper.js +323 -0
- package/dist/cjs/index.d.ts +37 -0
- package/dist/cjs/index.js +305 -0
- package/dist/cjs/jwtUtils.d.ts +35 -0
- package/dist/cjs/jwtUtils.js +61 -0
- package/dist/cjs/kek/index.d.ts +89 -0
- package/dist/cjs/kek/index.js +208 -0
- package/dist/cjs/kek/linux.d.ts +92 -0
- package/dist/cjs/kek/linux.js +596 -0
- package/dist/cjs/kek/macos.d.ts +112 -0
- package/dist/cjs/kek/macos.js +599 -0
- package/dist/cjs/kek/types.d.ts +111 -0
- package/dist/cjs/kek/types.js +43 -0
- package/dist/cjs/kek/windows.d.ts +71 -0
- package/dist/cjs/kek/windows.js +350 -0
- package/dist/cjs/kek/zeroize-registry.d.ts +44 -0
- package/dist/cjs/kek/zeroize-registry.js +63 -0
- package/dist/cjs/legacyStatePreflight.d.ts +67 -0
- package/dist/cjs/legacyStatePreflight.js +78 -0
- package/dist/cjs/localIdentity.d.ts +54 -0
- package/dist/cjs/localIdentity.js +57 -0
- package/dist/cjs/loginLocalhost.d.ts +115 -0
- package/dist/cjs/loginLocalhost.js +368 -0
- package/dist/cjs/loginWithLocalhost.d.ts +131 -0
- package/dist/cjs/loginWithLocalhost.js +359 -0
- package/dist/cjs/machineId.d.ts +35 -0
- package/dist/cjs/machineId.js +239 -0
- package/dist/cjs/messages.d.ts +9 -0
- package/dist/cjs/messages.js +44 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/pkce.d.ts +62 -0
- package/dist/cjs/pkce.js +158 -0
- package/dist/cjs/recoveryCode.d.ts +61 -0
- package/dist/cjs/recoveryCode.js +213 -0
- package/dist/cjs/refresh.d.ts +215 -0
- package/dist/cjs/refresh.js +877 -0
- package/dist/cjs/refreshClassification.d.ts +53 -0
- package/dist/cjs/refreshClassification.js +105 -0
- package/dist/cjs/refreshScheduler.d.ts +53 -0
- package/dist/cjs/refreshScheduler.js +332 -0
- package/dist/cjs/runtime.d.ts +149 -0
- package/dist/cjs/runtime.js +136 -0
- package/dist/cjs/spawnEnv.d.ts +76 -0
- package/dist/cjs/spawnEnv.js +153 -0
- package/dist/cjs/ttlParser.d.ts +54 -0
- package/dist/cjs/ttlParser.js +87 -0
- package/dist/cjs/types.d.ts +91 -0
- package/dist/cjs/types.js +26 -0
- package/dist/esm/aead.d.ts +24 -0
- package/dist/esm/aead.js +57 -0
- package/dist/esm/authExpiredDetector.d.ts +65 -0
- package/dist/esm/authExpiredDetector.js +85 -0
- package/dist/esm/authHelper.d.ts +51 -0
- package/dist/esm/authHelper.js +311 -0
- package/dist/esm/credentialEnvelope.d.ts +85 -0
- package/dist/esm/credentialEnvelope.js +213 -0
- package/dist/esm/credentialEnvelopeBridge.d.ts +116 -0
- package/dist/esm/credentialEnvelopeBridge.js +620 -0
- package/dist/esm/credentialResolver.d.ts +278 -0
- package/dist/esm/credentialResolver.js +414 -0
- package/dist/esm/daemonPair.d.ts +172 -0
- package/dist/esm/daemonPair.js +476 -0
- package/dist/esm/daemonScopes.d.ts +74 -0
- package/dist/esm/daemonScopes.js +80 -0
- package/dist/esm/deviceIdentityBridge.d.ts +113 -0
- package/dist/esm/deviceIdentityBridge.js +572 -0
- package/dist/esm/deviceKey.d.ts +167 -0
- package/dist/esm/deviceKey.js +320 -0
- package/dist/esm/fdToken.d.ts +17 -0
- package/dist/esm/fdToken.js +231 -0
- package/dist/esm/harnessTrust.d.ts +72 -0
- package/dist/esm/harnessTrust.js +97 -0
- package/dist/esm/headers-helper.d.ts +25 -0
- package/dist/esm/headers-helper.js +316 -0
- package/dist/esm/index.d.ts +37 -0
- package/dist/esm/index.js +144 -0
- package/dist/esm/jwtUtils.d.ts +35 -0
- package/dist/esm/jwtUtils.js +57 -0
- package/dist/esm/kek/index.d.ts +89 -0
- package/dist/esm/kek/index.js +162 -0
- package/dist/esm/kek/linux.d.ts +92 -0
- package/dist/esm/kek/linux.js +585 -0
- package/dist/esm/kek/macos.d.ts +112 -0
- package/dist/esm/kek/macos.js +586 -0
- package/dist/esm/kek/types.d.ts +111 -0
- package/dist/esm/kek/types.js +39 -0
- package/dist/esm/kek/windows.d.ts +71 -0
- package/dist/esm/kek/windows.js +344 -0
- package/dist/esm/kek/zeroize-registry.d.ts +44 -0
- package/dist/esm/kek/zeroize-registry.js +58 -0
- package/dist/esm/legacyStatePreflight.d.ts +67 -0
- package/dist/esm/legacyStatePreflight.js +71 -0
- package/dist/esm/localIdentity.d.ts +54 -0
- package/dist/esm/localIdentity.js +54 -0
- package/dist/esm/loginLocalhost.d.ts +115 -0
- package/dist/esm/loginLocalhost.js +355 -0
- package/dist/esm/loginWithLocalhost.d.ts +131 -0
- package/dist/esm/loginWithLocalhost.js +353 -0
- package/dist/esm/machineId.d.ts +35 -0
- package/dist/esm/machineId.js +231 -0
- package/dist/esm/messages.d.ts +9 -0
- package/dist/esm/messages.js +40 -0
- package/dist/esm/pkce.d.ts +62 -0
- package/dist/esm/pkce.js +148 -0
- package/dist/esm/recoveryCode.d.ts +61 -0
- package/dist/esm/recoveryCode.js +207 -0
- package/dist/esm/refresh.d.ts +215 -0
- package/dist/esm/refresh.js +863 -0
- package/dist/esm/refreshClassification.d.ts +53 -0
- package/dist/esm/refreshClassification.js +106 -0
- package/dist/esm/refreshScheduler.d.ts +53 -0
- package/dist/esm/refreshScheduler.js +329 -0
- package/dist/esm/runtime.d.ts +149 -0
- package/dist/esm/runtime.js +121 -0
- package/dist/esm/spawnEnv.d.ts +76 -0
- package/dist/esm/spawnEnv.js +149 -0
- package/dist/esm/ttlParser.d.ts +54 -0
- package/dist/esm/ttlParser.js +83 -0
- package/dist/esm/types.d.ts +91 -0
- package/dist/esm/types.js +21 -0
- package/package.json +82 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type DeviceKeyPair, type Ed25519PublicJwk } from './deviceKey.js';
|
|
2
|
+
type DeviceIdentityState = {
|
|
3
|
+
kind: 'uninit';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'disabled';
|
|
6
|
+
reason: DisabledReason;
|
|
7
|
+
} | {
|
|
8
|
+
kind: 'active';
|
|
9
|
+
keypair: DeviceKeyPair;
|
|
10
|
+
profile: string;
|
|
11
|
+
envelopeProtected: boolean;
|
|
12
|
+
};
|
|
13
|
+
type DisabledReason = 'init_failed' | 'load_failed' | 'persist_failed';
|
|
14
|
+
/** @internal Test seam — force-set state. */
|
|
15
|
+
export declare function __setStateForTest(next: DeviceIdentityState): DeviceIdentityState;
|
|
16
|
+
/** @internal Test seam — read state. */
|
|
17
|
+
export declare function __getStateForTest(): DeviceIdentityState;
|
|
18
|
+
/**
|
|
19
|
+
* Initialize device identity. Idempotent.
|
|
20
|
+
*
|
|
21
|
+
* Order:
|
|
22
|
+
* 1. Try to load existing keypair from disk.
|
|
23
|
+
* - Success → state = active.
|
|
24
|
+
* - Missing files / corruption / decrypt failure → step 2.
|
|
25
|
+
* 2. Generate fresh keypair and persist. Failure to persist is a hard
|
|
26
|
+
* error landing in disabled+persist_failed.
|
|
27
|
+
*/
|
|
28
|
+
export declare function initDeviceIdentity(profile: string): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Reset state. With `clearOnDisk=true` deletes both keypair files —
|
|
31
|
+
* used by `oversky logout` / `sky logout` so a freshly-logged-in user
|
|
32
|
+
* gets a fresh device identity that the daemon will see on its next start.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resetDeviceIdentity(opts?: {
|
|
35
|
+
profile?: string;
|
|
36
|
+
clearOnDisk?: boolean;
|
|
37
|
+
}): Promise<void>;
|
|
38
|
+
/** True iff init has completed and a keypair is in memory. */
|
|
39
|
+
export declare function isDeviceIdentityActive(): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Export the public key for server enrollment / dashboard display.
|
|
42
|
+
* Returns null when not active so callers know to skip the JWS header
|
|
43
|
+
* on the in-flight request.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getDevicePublicKey(): {
|
|
46
|
+
jwk: Ed25519PublicJwk;
|
|
47
|
+
thumbprint: string;
|
|
48
|
+
} | null;
|
|
49
|
+
/**
|
|
50
|
+
* Operator-readable summary of device-identity state. Used by
|
|
51
|
+
* `oversky status` and CLI debug commands. Never includes the private key.
|
|
52
|
+
*/
|
|
53
|
+
export declare function describeDeviceIdentityState(): {
|
|
54
|
+
kind: DeviceIdentityState['kind'];
|
|
55
|
+
reason?: DisabledReason;
|
|
56
|
+
thumbprint?: string;
|
|
57
|
+
profile?: string;
|
|
58
|
+
envelopeProtected?: boolean;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Sign an outgoing request with the device key. Returns the JWS Compact
|
|
62
|
+
* Serialization, or null when device identity is not active. NEVER
|
|
63
|
+
* throws — a failed sign returns null and emits a telemetry event so
|
|
64
|
+
* the caller can fall back to bearer-only auth without an outage.
|
|
65
|
+
*
|
|
66
|
+
* Shape matches `DeviceProofSigner` from `./refresh.ts` so the function
|
|
67
|
+
* passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
|
|
68
|
+
*/
|
|
69
|
+
export declare function signServerRequest(opts: {
|
|
70
|
+
method: string;
|
|
71
|
+
url: string;
|
|
72
|
+
refreshToken?: string;
|
|
73
|
+
accessToken?: string;
|
|
74
|
+
}): string | null;
|
|
75
|
+
/** @internal Test seams — file path resolvers. */
|
|
76
|
+
export declare function __publicKeyPathForTest(profile: string): string;
|
|
77
|
+
export declare function __privateKeyPathForTest(profile: string): string;
|
|
78
|
+
export declare function __enrollmentMarkerPathForTest(profile: string): string;
|
|
79
|
+
export declare function __enrollmentBackoffPathForTest(profile: string): string;
|
|
80
|
+
/** Remove the enrollment marker so re-login gets a fresh enrollment. */
|
|
81
|
+
export declare function clearEnrollmentMarker(profile: string): void;
|
|
82
|
+
export type EnrollmentResult = {
|
|
83
|
+
enrolled: true;
|
|
84
|
+
thumbprint: string;
|
|
85
|
+
} | {
|
|
86
|
+
enrolled: false;
|
|
87
|
+
reason: 'not_active' | 'already_persisted' | 'backoff_active' | 'http_error' | 'network_error';
|
|
88
|
+
status?: number;
|
|
89
|
+
body?: string;
|
|
90
|
+
message?: string;
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* POST the device public key to the server for enrollment.
|
|
94
|
+
*
|
|
95
|
+
* Idempotent across boots — the marker file short-circuits subsequent
|
|
96
|
+
* calls. The marker stores serverUrl + thumbprint so a URL change
|
|
97
|
+
* (staging → prod swap) forces fresh enrollment.
|
|
98
|
+
*
|
|
99
|
+
* Fire-and-forget contract — callers should `.catch()` so a network
|
|
100
|
+
* partition at startup cannot stall the consumer's command path.
|
|
101
|
+
*
|
|
102
|
+
* NOTE: Both `sky` and `oversky` may race to enroll the same shared
|
|
103
|
+
* keypair. The server's `enroll-device-key` route is idempotent on
|
|
104
|
+
* `(machineUuid, publicJwkX)` so a duplicate POST is safe; whichever
|
|
105
|
+
* binary writes its marker file first short-circuits the other.
|
|
106
|
+
*/
|
|
107
|
+
export declare function enrollWithServer(opts: {
|
|
108
|
+
serverUrl: string;
|
|
109
|
+
accessToken: string;
|
|
110
|
+
machineUuid: string;
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
}): Promise<EnrollmentResult>;
|
|
113
|
+
export {};
|
|
@@ -0,0 +1,591 @@
|
|
|
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.__setStateForTest = __setStateForTest;
|
|
7
|
+
exports.__getStateForTest = __getStateForTest;
|
|
8
|
+
exports.initDeviceIdentity = initDeviceIdentity;
|
|
9
|
+
exports.resetDeviceIdentity = resetDeviceIdentity;
|
|
10
|
+
exports.isDeviceIdentityActive = isDeviceIdentityActive;
|
|
11
|
+
exports.getDevicePublicKey = getDevicePublicKey;
|
|
12
|
+
exports.describeDeviceIdentityState = describeDeviceIdentityState;
|
|
13
|
+
exports.signServerRequest = signServerRequest;
|
|
14
|
+
exports.__publicKeyPathForTest = __publicKeyPathForTest;
|
|
15
|
+
exports.__privateKeyPathForTest = __privateKeyPathForTest;
|
|
16
|
+
exports.__enrollmentMarkerPathForTest = __enrollmentMarkerPathForTest;
|
|
17
|
+
exports.__enrollmentBackoffPathForTest = __enrollmentBackoffPathForTest;
|
|
18
|
+
exports.clearEnrollmentMarker = clearEnrollmentMarker;
|
|
19
|
+
exports.enrollWithServer = enrollWithServer;
|
|
20
|
+
/**
|
|
21
|
+
* deviceIdentityBridge.ts — composed L11 runtime (init, sign, enroll).
|
|
22
|
+
*
|
|
23
|
+
* Lifts the previously-duplicated `daemon/src/deviceIdentity.ts` and
|
|
24
|
+
* `cli/src/lib/device-identity.ts` modules into a single shared
|
|
25
|
+
* implementation. Daemon and CLI both consume this module so they land
|
|
26
|
+
* on the SAME on-disk artifacts:
|
|
27
|
+
*
|
|
28
|
+
* <configDir>/profiles/<p>/device-public-key.jwk — plaintext JWK
|
|
29
|
+
* <configDir>/profiles/<p>/device-private-key — L12-wrapped JSON
|
|
30
|
+
* <configDir>/profiles/<p>/device-enrollment.json — server-ack marker
|
|
31
|
+
*
|
|
32
|
+
* Architectural decision (locked): CLI shares the daemon's keypair on
|
|
33
|
+
* the same machine. Same machine = same device identity. The scope
|
|
34
|
+
* discriminator is the JWT claim (`scope: 'cli'` vs `scope: 'daemon'`),
|
|
35
|
+
* not the key.
|
|
36
|
+
*
|
|
37
|
+
* NAMING NOTE — the suffix `Bridge` distinguishes this composed runtime
|
|
38
|
+
* (init + sign + enroll + telemetry) from the pure-crypto primitive
|
|
39
|
+
* file `deviceKey.ts` it composes.
|
|
40
|
+
*/
|
|
41
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
42
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
43
|
+
const deviceKey_js_1 = require("./deviceKey.js");
|
|
44
|
+
const credentialEnvelopeBridge_js_1 = require("./credentialEnvelopeBridge.js");
|
|
45
|
+
const runtime_js_1 = require("./runtime.js");
|
|
46
|
+
const PUBLIC_KEY_FILENAME = 'device-public-key.jwk';
|
|
47
|
+
const PRIVATE_KEY_FILENAME = 'device-private-key';
|
|
48
|
+
const ENROLLMENT_MARKER_FILENAME = 'device-enrollment.json';
|
|
49
|
+
const ENROLLMENT_BACKOFF_FILENAME = 'device-enrollment-backoff.json';
|
|
50
|
+
// A missing Daemon row cannot become enrollable until registration changes
|
|
51
|
+
// server state. Persist the negative result across short-lived `sky` processes
|
|
52
|
+
// so every CLI command does not repeat the same guaranteed 404.
|
|
53
|
+
const DAEMON_NOT_FOUND_BACKOFF_MS = 15 * 60 * 1000;
|
|
54
|
+
let _state = { kind: 'uninit' };
|
|
55
|
+
/** @internal Test seam — force-set state. */
|
|
56
|
+
function __setStateForTest(next) {
|
|
57
|
+
const prev = _state;
|
|
58
|
+
_state = next;
|
|
59
|
+
return prev;
|
|
60
|
+
}
|
|
61
|
+
/** @internal Test seam — read state. */
|
|
62
|
+
function __getStateForTest() {
|
|
63
|
+
return _state;
|
|
64
|
+
}
|
|
65
|
+
function profileConfigDir(profile) {
|
|
66
|
+
return node_path_1.default.join((0, runtime_js_1.getAuthConfigDir)(), 'profiles', profile);
|
|
67
|
+
}
|
|
68
|
+
function publicKeyPath(profile) {
|
|
69
|
+
return node_path_1.default.join(profileConfigDir(profile), PUBLIC_KEY_FILENAME);
|
|
70
|
+
}
|
|
71
|
+
function privateKeyPath(profile) {
|
|
72
|
+
return node_path_1.default.join(profileConfigDir(profile), PRIVATE_KEY_FILENAME);
|
|
73
|
+
}
|
|
74
|
+
function enrollmentMarkerPath(profile) {
|
|
75
|
+
return node_path_1.default.join(profileConfigDir(profile), ENROLLMENT_MARKER_FILENAME);
|
|
76
|
+
}
|
|
77
|
+
function enrollmentBackoffPath(profile) {
|
|
78
|
+
return node_path_1.default.join(profileConfigDir(profile), ENROLLMENT_BACKOFF_FILENAME);
|
|
79
|
+
}
|
|
80
|
+
function atomicWrite0600(target, value) {
|
|
81
|
+
const dir = node_path_1.default.dirname(target);
|
|
82
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
83
|
+
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
84
|
+
const buf = typeof value === 'string' ? Buffer.from(value, 'utf-8') : value;
|
|
85
|
+
const fd = node_fs_1.default.openSync(tmp, 'w', 0o600);
|
|
86
|
+
try {
|
|
87
|
+
node_fs_1.default.writeSync(fd, buf);
|
|
88
|
+
node_fs_1.default.fsyncSync(fd);
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
node_fs_1.default.closeSync(fd);
|
|
92
|
+
}
|
|
93
|
+
node_fs_1.default.renameSync(tmp, target);
|
|
94
|
+
try {
|
|
95
|
+
const dirFd = node_fs_1.default.openSync(dir, 'r');
|
|
96
|
+
try {
|
|
97
|
+
node_fs_1.default.fsyncSync(dirFd);
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
node_fs_1.default.closeSync(dirFd);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
/* best-effort; some platforms reject dir fsync */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function emit(eventName, success, metadata, opts = {}) {
|
|
108
|
+
(0, runtime_js_1.emitAuthTelemetry)({
|
|
109
|
+
timestamp: new Date().toISOString(),
|
|
110
|
+
eventType: 'auth',
|
|
111
|
+
success,
|
|
112
|
+
metadata: { source: 'deviceIdentity', event: eventName, ...metadata },
|
|
113
|
+
});
|
|
114
|
+
if (!success) {
|
|
115
|
+
const level = opts.logLevel ?? 'warn';
|
|
116
|
+
const log = (0, runtime_js_1.getAuthLogger)();
|
|
117
|
+
if (level === 'info') {
|
|
118
|
+
log.info(`[deviceIdentity] ${eventName}`, metadata);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
log.warn(`[deviceIdentity] ${eventName}`, metadata);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Persist a freshly-generated keypair. Public goes plaintext; private
|
|
127
|
+
* goes through `maybeEncryptForWrite` so L12 wraps it when active.
|
|
128
|
+
*/
|
|
129
|
+
function persistKeypair(profile, kp) {
|
|
130
|
+
const publicPayload = JSON.stringify({
|
|
131
|
+
publicJwk: kp.publicJwk,
|
|
132
|
+
thumbprint: kp.thumbprint,
|
|
133
|
+
createdAt: new Date().toISOString(),
|
|
134
|
+
});
|
|
135
|
+
atomicWrite0600(publicKeyPath(profile), publicPayload);
|
|
136
|
+
// Use the always-fail-open variant — device-identity wants the private
|
|
137
|
+
// key persisted even when the L12 envelope path is inactive (today's
|
|
138
|
+
// default on every platform). The daemon's `credEnvelopeFailClosed`
|
|
139
|
+
// policy applies only to the keychain.ts write path.
|
|
140
|
+
const privatePlaintext = JSON.stringify(kp.privateJwk);
|
|
141
|
+
const wireForm = (0, credentialEnvelopeBridge_js_1.wrapIfActiveOrPassthrough)(privatePlaintext);
|
|
142
|
+
const envelopeProtected = wireForm !== privatePlaintext;
|
|
143
|
+
atomicWrite0600(privateKeyPath(profile), wireForm);
|
|
144
|
+
emit('keypair_persisted', true, {
|
|
145
|
+
profile,
|
|
146
|
+
thumbprint: kp.thumbprint,
|
|
147
|
+
envelopeProtected,
|
|
148
|
+
});
|
|
149
|
+
return { envelopeProtected };
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Load a previously-persisted keypair. Returns null on missing files,
|
|
153
|
+
* decrypt failure, public/private mismatch (file-swap defense), or
|
|
154
|
+
* malformed JSON. Throws only on unexpected fs errors; init catches.
|
|
155
|
+
*/
|
|
156
|
+
function loadKeypair(profile) {
|
|
157
|
+
const pubPath = publicKeyPath(profile);
|
|
158
|
+
const privPath = privateKeyPath(profile);
|
|
159
|
+
if (!node_fs_1.default.existsSync(pubPath) || !node_fs_1.default.existsSync(privPath)) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
const pubRaw = node_fs_1.default.readFileSync(pubPath, 'utf-8');
|
|
163
|
+
const privRaw = node_fs_1.default.readFileSync(privPath, 'utf-8');
|
|
164
|
+
let publicJwk;
|
|
165
|
+
let storedThumbprint;
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(pubRaw);
|
|
168
|
+
publicJwk = parsed.publicJwk;
|
|
169
|
+
storedThumbprint = parsed.thumbprint;
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
emit('public_key_unreadable', false, {
|
|
173
|
+
profile,
|
|
174
|
+
message: err?.message ?? 'unknown',
|
|
175
|
+
});
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const decryptResult = (0, credentialEnvelopeBridge_js_1.maybeDecryptOnRead)(privRaw);
|
|
179
|
+
if (decryptResult.plaintext === null) {
|
|
180
|
+
emit('private_key_decrypt_failed', false, { profile });
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const privatePlaintext = decryptResult.plaintext;
|
|
184
|
+
let privateJwk;
|
|
185
|
+
try {
|
|
186
|
+
privateJwk = JSON.parse(privatePlaintext);
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
emit('private_key_malformed', false, {
|
|
190
|
+
profile,
|
|
191
|
+
message: err?.message ?? 'unknown',
|
|
192
|
+
});
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
// public.x must match private.x — any mismatch indicates a partial
|
|
196
|
+
// write race, manual edit, or attacker swapping one of the files.
|
|
197
|
+
if (publicJwk.x !== privateJwk.x) {
|
|
198
|
+
emit('keypair_inconsistent', false, { profile });
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
// Recompute the thumbprint; mismatch with stored is informational
|
|
202
|
+
// (we trust the recomputed value), not a failure.
|
|
203
|
+
const recomputed = (0, deviceKey_js_1.jwkThumbprint)(publicJwk);
|
|
204
|
+
if (recomputed !== storedThumbprint) {
|
|
205
|
+
emit('thumbprint_drift', false, {
|
|
206
|
+
profile,
|
|
207
|
+
stored: storedThumbprint,
|
|
208
|
+
recomputed,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
publicJwk,
|
|
213
|
+
privateJwk,
|
|
214
|
+
thumbprint: recomputed,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Initialize device identity. Idempotent.
|
|
219
|
+
*
|
|
220
|
+
* Order:
|
|
221
|
+
* 1. Try to load existing keypair from disk.
|
|
222
|
+
* - Success → state = active.
|
|
223
|
+
* - Missing files / corruption / decrypt failure → step 2.
|
|
224
|
+
* 2. Generate fresh keypair and persist. Failure to persist is a hard
|
|
225
|
+
* error landing in disabled+persist_failed.
|
|
226
|
+
*/
|
|
227
|
+
async function initDeviceIdentity(profile) {
|
|
228
|
+
if (_state.kind !== 'uninit')
|
|
229
|
+
return;
|
|
230
|
+
if (typeof profile !== 'string' || profile.length === 0) {
|
|
231
|
+
_state = { kind: 'disabled', reason: 'init_failed' };
|
|
232
|
+
emit('init_failed', false, { reason: 'empty_profile' });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
let keypair = null;
|
|
236
|
+
try {
|
|
237
|
+
keypair = loadKeypair(profile);
|
|
238
|
+
}
|
|
239
|
+
catch (err) {
|
|
240
|
+
emit('load_failed', false, {
|
|
241
|
+
profile,
|
|
242
|
+
message: err?.message ?? 'unknown',
|
|
243
|
+
});
|
|
244
|
+
keypair = null;
|
|
245
|
+
}
|
|
246
|
+
if (keypair) {
|
|
247
|
+
const envelopeProtected = (0, credentialEnvelopeBridge_js_1.isCredEnvelopeActive)();
|
|
248
|
+
_state = { kind: 'active', keypair, profile, envelopeProtected };
|
|
249
|
+
emit('active', true, {
|
|
250
|
+
profile,
|
|
251
|
+
thumbprint: keypair.thumbprint,
|
|
252
|
+
envelopeProtected,
|
|
253
|
+
regenerated: false,
|
|
254
|
+
});
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let fresh;
|
|
258
|
+
try {
|
|
259
|
+
fresh = (0, deviceKey_js_1.generateDeviceKeyPair)();
|
|
260
|
+
}
|
|
261
|
+
catch (err) {
|
|
262
|
+
_state = { kind: 'disabled', reason: 'init_failed' };
|
|
263
|
+
emit('init_failed', false, {
|
|
264
|
+
profile,
|
|
265
|
+
reason: 'keygen_threw',
|
|
266
|
+
message: err?.message ?? 'unknown',
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
let persistResult;
|
|
271
|
+
try {
|
|
272
|
+
persistResult = persistKeypair(profile, fresh);
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
_state = { kind: 'disabled', reason: 'persist_failed' };
|
|
276
|
+
emit('persist_failed', false, {
|
|
277
|
+
profile,
|
|
278
|
+
message: err?.message ?? 'unknown',
|
|
279
|
+
});
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
_state = {
|
|
283
|
+
kind: 'active',
|
|
284
|
+
keypair: fresh,
|
|
285
|
+
profile,
|
|
286
|
+
envelopeProtected: persistResult.envelopeProtected,
|
|
287
|
+
};
|
|
288
|
+
emit('active', true, {
|
|
289
|
+
profile,
|
|
290
|
+
thumbprint: fresh.thumbprint,
|
|
291
|
+
envelopeProtected: persistResult.envelopeProtected,
|
|
292
|
+
regenerated: true,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Reset state. With `clearOnDisk=true` deletes both keypair files —
|
|
297
|
+
* used by `oversky logout` / `sky logout` so a freshly-logged-in user
|
|
298
|
+
* gets a fresh device identity that the daemon will see on its next start.
|
|
299
|
+
*/
|
|
300
|
+
async function resetDeviceIdentity(opts) {
|
|
301
|
+
if (opts?.clearOnDisk && opts.profile) {
|
|
302
|
+
for (const p of [publicKeyPath(opts.profile), privateKeyPath(opts.profile)]) {
|
|
303
|
+
try {
|
|
304
|
+
node_fs_1.default.unlinkSync(p);
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
if (err.code !== 'ENOENT') {
|
|
308
|
+
emit('clear_failed', false, {
|
|
309
|
+
profile: opts.profile,
|
|
310
|
+
file: node_path_1.default.basename(p),
|
|
311
|
+
message: err?.message ?? 'unknown',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
_state = { kind: 'uninit' };
|
|
318
|
+
}
|
|
319
|
+
/** True iff init has completed and a keypair is in memory. */
|
|
320
|
+
function isDeviceIdentityActive() {
|
|
321
|
+
return _state.kind === 'active';
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Export the public key for server enrollment / dashboard display.
|
|
325
|
+
* Returns null when not active so callers know to skip the JWS header
|
|
326
|
+
* on the in-flight request.
|
|
327
|
+
*/
|
|
328
|
+
function getDevicePublicKey() {
|
|
329
|
+
if (_state.kind !== 'active')
|
|
330
|
+
return null;
|
|
331
|
+
return { jwk: _state.keypair.publicJwk, thumbprint: _state.keypair.thumbprint };
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Operator-readable summary of device-identity state. Used by
|
|
335
|
+
* `oversky status` and CLI debug commands. Never includes the private key.
|
|
336
|
+
*/
|
|
337
|
+
function describeDeviceIdentityState() {
|
|
338
|
+
if (_state.kind === 'disabled') {
|
|
339
|
+
return { kind: 'disabled', reason: _state.reason };
|
|
340
|
+
}
|
|
341
|
+
if (_state.kind === 'active') {
|
|
342
|
+
return {
|
|
343
|
+
kind: 'active',
|
|
344
|
+
thumbprint: _state.keypair.thumbprint,
|
|
345
|
+
profile: _state.profile,
|
|
346
|
+
envelopeProtected: _state.envelopeProtected,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return { kind: 'uninit' };
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Sign an outgoing request with the device key. Returns the JWS Compact
|
|
353
|
+
* Serialization, or null when device identity is not active. NEVER
|
|
354
|
+
* throws — a failed sign returns null and emits a telemetry event so
|
|
355
|
+
* the caller can fall back to bearer-only auth without an outage.
|
|
356
|
+
*
|
|
357
|
+
* Shape matches `DeviceProofSigner` from `./refresh.ts` so the function
|
|
358
|
+
* passes directly into `refreshDaemonToken`'s `signRequestProof` slot.
|
|
359
|
+
*/
|
|
360
|
+
function signServerRequest(opts) {
|
|
361
|
+
if (_state.kind !== 'active')
|
|
362
|
+
return null;
|
|
363
|
+
try {
|
|
364
|
+
return (0, deviceKey_js_1.buildProof)(_state.keypair.privateJwk, _state.keypair.publicJwk, {
|
|
365
|
+
method: opts.method,
|
|
366
|
+
url: opts.url,
|
|
367
|
+
refreshToken: opts.refreshToken,
|
|
368
|
+
accessToken: opts.accessToken,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
emit('sign_failed', false, {
|
|
373
|
+
profile: _state.profile,
|
|
374
|
+
thumbprint: _state.keypair.thumbprint,
|
|
375
|
+
method: opts.method,
|
|
376
|
+
message: err?.message ?? 'unknown',
|
|
377
|
+
});
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** @internal Test seams — file path resolvers. */
|
|
382
|
+
function __publicKeyPathForTest(profile) {
|
|
383
|
+
return publicKeyPath(profile);
|
|
384
|
+
}
|
|
385
|
+
function __privateKeyPathForTest(profile) {
|
|
386
|
+
return privateKeyPath(profile);
|
|
387
|
+
}
|
|
388
|
+
function __enrollmentMarkerPathForTest(profile) {
|
|
389
|
+
return enrollmentMarkerPath(profile);
|
|
390
|
+
}
|
|
391
|
+
function __enrollmentBackoffPathForTest(profile) {
|
|
392
|
+
return enrollmentBackoffPath(profile);
|
|
393
|
+
}
|
|
394
|
+
/** Remove the enrollment marker so re-login gets a fresh enrollment. */
|
|
395
|
+
function clearEnrollmentMarker(profile) {
|
|
396
|
+
for (const markerPath of [enrollmentMarkerPath(profile), enrollmentBackoffPath(profile)]) {
|
|
397
|
+
try {
|
|
398
|
+
node_fs_1.default.unlinkSync(markerPath);
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
if (err.code !== 'ENOENT') {
|
|
402
|
+
emit('enrollment_marker_clear_failed', false, {
|
|
403
|
+
profile,
|
|
404
|
+
file: node_path_1.default.basename(markerPath),
|
|
405
|
+
message: err?.message ?? 'unknown',
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* POST the device public key to the server for enrollment.
|
|
413
|
+
*
|
|
414
|
+
* Idempotent across boots — the marker file short-circuits subsequent
|
|
415
|
+
* calls. The marker stores serverUrl + thumbprint so a URL change
|
|
416
|
+
* (staging → prod swap) forces fresh enrollment.
|
|
417
|
+
*
|
|
418
|
+
* Fire-and-forget contract — callers should `.catch()` so a network
|
|
419
|
+
* partition at startup cannot stall the consumer's command path.
|
|
420
|
+
*
|
|
421
|
+
* NOTE: Both `sky` and `oversky` may race to enroll the same shared
|
|
422
|
+
* keypair. The server's `enroll-device-key` route is idempotent on
|
|
423
|
+
* `(machineUuid, publicJwkX)` so a duplicate POST is safe; whichever
|
|
424
|
+
* binary writes its marker file first short-circuits the other.
|
|
425
|
+
*/
|
|
426
|
+
async function enrollWithServer(opts) {
|
|
427
|
+
const { serverUrl, accessToken, machineUuid } = opts;
|
|
428
|
+
if (_state.kind !== 'active') {
|
|
429
|
+
return { enrolled: false, reason: 'not_active' };
|
|
430
|
+
}
|
|
431
|
+
const profile = _state.profile;
|
|
432
|
+
const keypair = _state.keypair;
|
|
433
|
+
const markerPath = enrollmentMarkerPath(profile);
|
|
434
|
+
const backoffPath = enrollmentBackoffPath(profile);
|
|
435
|
+
try {
|
|
436
|
+
if (node_fs_1.default.existsSync(markerPath)) {
|
|
437
|
+
return { enrolled: false, reason: 'already_persisted' };
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
/* best-effort */
|
|
442
|
+
}
|
|
443
|
+
try {
|
|
444
|
+
const raw = JSON.parse(node_fs_1.default.readFileSync(backoffPath, 'utf-8'));
|
|
445
|
+
const retryAfter = Date.parse(raw.retryAfter || '');
|
|
446
|
+
if (raw.serverUrl === serverUrl &&
|
|
447
|
+
raw.machineUuid === machineUuid &&
|
|
448
|
+
raw.thumbprint === keypair.thumbprint &&
|
|
449
|
+
Number.isFinite(retryAfter) &&
|
|
450
|
+
retryAfter > Date.now()) {
|
|
451
|
+
return { enrolled: false, reason: 'backoff_active' };
|
|
452
|
+
}
|
|
453
|
+
node_fs_1.default.unlinkSync(backoffPath);
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
if (err.code !== 'ENOENT') {
|
|
457
|
+
// A corrupt/stale marker must never suppress enrollment indefinitely.
|
|
458
|
+
try {
|
|
459
|
+
node_fs_1.default.unlinkSync(backoffPath);
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
/* best-effort */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
const base = serverUrl.replace(/\/+$/, '');
|
|
467
|
+
const htu = `${base}/api/daemon-resources/enroll-device-key`;
|
|
468
|
+
const proofJws = (0, deviceKey_js_1.buildProof)(keypair.privateJwk, keypair.publicJwk, {
|
|
469
|
+
method: 'POST',
|
|
470
|
+
url: htu,
|
|
471
|
+
});
|
|
472
|
+
const body = JSON.stringify({
|
|
473
|
+
machineUuid,
|
|
474
|
+
publicJwkX: keypair.publicJwk.x,
|
|
475
|
+
proofJws,
|
|
476
|
+
});
|
|
477
|
+
// 10-second bound — enrollment is best-effort at startup.
|
|
478
|
+
const ownAbort = new AbortController();
|
|
479
|
+
const timer = setTimeout(() => ownAbort.abort(), 10_000);
|
|
480
|
+
let response;
|
|
481
|
+
try {
|
|
482
|
+
response = await fetch(htu, {
|
|
483
|
+
method: 'POST',
|
|
484
|
+
headers: {
|
|
485
|
+
'Content-Type': 'application/json',
|
|
486
|
+
Authorization: `Bearer ${accessToken}`,
|
|
487
|
+
},
|
|
488
|
+
body,
|
|
489
|
+
signal: opts.signal ?? ownAbort.signal,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
catch (err) {
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
const message = err?.message ?? 'unknown';
|
|
495
|
+
emit('enrollment_network_error', false, {
|
|
496
|
+
profile,
|
|
497
|
+
thumbprint: keypair.thumbprint,
|
|
498
|
+
serverUrl,
|
|
499
|
+
message,
|
|
500
|
+
});
|
|
501
|
+
return { enrolled: false, reason: 'network_error', message };
|
|
502
|
+
}
|
|
503
|
+
finally {
|
|
504
|
+
clearTimeout(timer);
|
|
505
|
+
}
|
|
506
|
+
const responseText = await response.text().catch(() => '');
|
|
507
|
+
if (!response.ok) {
|
|
508
|
+
// Some rejection statuses are expected, non-actionable states rather
|
|
509
|
+
// than failures — log them at info so they don't read as auth errors or
|
|
510
|
+
// spam stderr on every invocation. Other statuses (proof failures, 5xx,
|
|
511
|
+
// etc.) keep the louder warn level for ops visibility.
|
|
512
|
+
// • 404 DAEMON_NOT_FOUND — pre-enrollment window: the daemon row for
|
|
513
|
+
// this (user, machineUuid) pair does not exist yet (fresh install,
|
|
514
|
+
// server swap, or daemon hasn't started). Fires every CLI command
|
|
515
|
+
// in that window.
|
|
516
|
+
// • 403 OPAQUE_AUTH_REFUSED — the enroll route is JWT-only (M11); an
|
|
517
|
+
// opaque `osk_dmn_*` daemon token is refused by design (OSK-1660 #5).
|
|
518
|
+
// Enrollment is the CLI's (JWT) job; the daemon holding an opaque
|
|
519
|
+
// token should not enroll. Guarded at the daemon call site, this is
|
|
520
|
+
// defense-in-depth for any other opaque caller.
|
|
521
|
+
// • 409 already-enrolled — a concurrent binary won the enrollment race
|
|
522
|
+
// for the shared keypair; idempotent, nothing to do.
|
|
523
|
+
const isBenignRejection = (response.status === 404 && responseText.includes('DAEMON_NOT_FOUND')) ||
|
|
524
|
+
(response.status === 403 && responseText.includes('OPAQUE_AUTH_REFUSED')) ||
|
|
525
|
+
response.status === 409;
|
|
526
|
+
if (response.status === 404 && responseText.includes('DAEMON_NOT_FOUND')) {
|
|
527
|
+
try {
|
|
528
|
+
atomicWrite0600(backoffPath, JSON.stringify({
|
|
529
|
+
serverUrl,
|
|
530
|
+
machineUuid,
|
|
531
|
+
thumbprint: keypair.thumbprint,
|
|
532
|
+
retryAfter: new Date(Date.now() + DAEMON_NOT_FOUND_BACKOFF_MS).toISOString(),
|
|
533
|
+
}));
|
|
534
|
+
}
|
|
535
|
+
catch (err) {
|
|
536
|
+
emit('enrollment_backoff_write_failed', false, {
|
|
537
|
+
profile,
|
|
538
|
+
message: err?.message ?? 'unknown',
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
emit('enrollment_rejected', false, {
|
|
543
|
+
profile,
|
|
544
|
+
thumbprint: keypair.thumbprint,
|
|
545
|
+
serverUrl,
|
|
546
|
+
status: response.status,
|
|
547
|
+
body: responseText,
|
|
548
|
+
}, { logLevel: isBenignRejection ? 'info' : 'warn' });
|
|
549
|
+
return {
|
|
550
|
+
enrolled: false,
|
|
551
|
+
reason: 'http_error',
|
|
552
|
+
status: response.status,
|
|
553
|
+
body: responseText,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
const marker = JSON.stringify({
|
|
557
|
+
enrolledAt: new Date().toISOString(),
|
|
558
|
+
serverUrl,
|
|
559
|
+
thumbprint: keypair.thumbprint,
|
|
560
|
+
machineUuid,
|
|
561
|
+
});
|
|
562
|
+
try {
|
|
563
|
+
atomicWrite0600(markerPath, marker);
|
|
564
|
+
}
|
|
565
|
+
catch (err) {
|
|
566
|
+
// Non-fatal — server enrolled us; we may re-POST next run, which
|
|
567
|
+
// the server handles idempotently.
|
|
568
|
+
emit('enrollment_marker_write_failed', false, {
|
|
569
|
+
profile,
|
|
570
|
+
thumbprint: keypair.thumbprint,
|
|
571
|
+
message: err?.message ?? 'unknown',
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
try {
|
|
575
|
+
node_fs_1.default.unlinkSync(backoffPath);
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
if (err.code !== 'ENOENT') {
|
|
579
|
+
emit('enrollment_backoff_clear_failed', false, {
|
|
580
|
+
profile,
|
|
581
|
+
message: err?.message ?? 'unknown',
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
emit('enrolled_with_server', true, {
|
|
586
|
+
profile,
|
|
587
|
+
thumbprint: keypair.thumbprint,
|
|
588
|
+
serverUrl,
|
|
589
|
+
});
|
|
590
|
+
return { enrolled: true, thumbprint: keypair.thumbprint };
|
|
591
|
+
}
|