fix(auth): address Copilot review round 6 on the secret-store PR
Four findings, all suppressed, all real. Three are the same mistake in different places: a state that was never established being reported as a confident answer. - **`readOnDiskEncryption` conflated "no file" with "unreadable file".** Both answered `null`, and the descriptor falls back to the configured write policy on `null` — so a passphrase-configured install reported "File (encrypted)" about a file whose envelope had never parsed, and which `set` was in fact about to refuse rather than overwrite. It now returns a four-state result, and only `absent` falls back to the policy (honestly: the first write really will create it that way). `unreadable` produces `encryptionUnknown` on the descriptor, `plaintext` is omitted entirely so nothing can read a default out of it, and the label, caveat, tone and footer all carry "unreadable" rather than guessing a mode. - **The loose-mode warning overstated exposure for an encrypted file.** "Anyone who can read it can read the secrets in it" is false when the file is ciphertext — a reader gets the bytes and still needs the passphrase. Both the caveat and the footer tooltip now branch on encryption: the passphrase becomes the only protection, which is the real consequence. Overstating it is not harmless, because a warning that cries wolf is discounted on the occasion it is literally true. - **The keychain hand-off could invert its own keychain-wins rule.** `SecretStore.get` answers `null` both for a missing entry and for a keychain it could not read, and the migration *writes* on `null` — so a transient read failure would overwrite a newer keychain value with the older file copy. Added an optional `getStrict` (throws instead of swallowing) implemented by `KeyringSecretStore`, with `secretStoreGetStrict` falling back to `get` for stores whose reads cannot fail. A throw now aborts the hand-off with the file left in place, so the next run retries. Writing the tests turned up one more, in code the review had passed: `parseOwner` recorded a garbled pid as `-1`, and `process.kill(-1, 0)` is a *wildcard* asking about every process this user can signal — it answers "alive" almost always, which would have made a lock with a corrupt stamp permanently unstealable. Only a positive pid is usable now; anything else is treated as no pid at all and the mtime decides. Tests: the four envelope states kept distinct; the descriptor refusing to guess, with the genuine no-file case as the control; strict-read abort without a write and with the file preserved, plus a store whose `get` and `getStrict` disagree so the test proves which one the migration consults; encryption-aware wording asserted in both directions in the caveat and the band; three unusable owner stamps and a wildcard pid; and the descriptor shapes that must now be rejected — neither half of the encryption question answered, or both. Full `npm run ci` green — 5,750 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
1aa1f5a0ca
commit
fdeffa1f00
11 changed files with 489 additions and 29 deletions
|
|
@ -134,4 +134,43 @@ describe("SecretStorageFooter", () => {
|
||||||
expect(band).not.toHaveTextContent("Owner-only");
|
expect(band).not.toHaveTextContent("Owner-only");
|
||||||
expect(band).toHaveAttribute("data-tone", "warn");
|
expect(band).toHaveAttribute("data-tone", "warn");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("says an unreadable file is unreadable, not encrypted", async () => {
|
||||||
|
// The descriptor omits `plaintext` here, and the band must not read a
|
||||||
|
// default out of that absence.
|
||||||
|
renderWithMantine(
|
||||||
|
<SecretStorageFooter
|
||||||
|
info={{
|
||||||
|
kind: "file",
|
||||||
|
reason: "fallback",
|
||||||
|
durable: true,
|
||||||
|
path: "/home/node/.mcp-inspector/secrets.json",
|
||||||
|
encryptionUnknown: "not valid JSON",
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const band = screen.getByTestId("secret-storage-footer");
|
||||||
|
expect(band).toHaveTextContent("Unreadable file.");
|
||||||
|
expect(band).toHaveTextContent("Saving a secret will fail.");
|
||||||
|
expect(band).not.toHaveTextContent("Encrypted file");
|
||||||
|
expect(band).toHaveAttribute("data-tone", "warn");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not claim a loose-mode encrypted file exposes its secrets", async () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<SecretStorageFooter
|
||||||
|
info={{
|
||||||
|
kind: "file",
|
||||||
|
reason: "fallback",
|
||||||
|
durable: true,
|
||||||
|
plaintext: false,
|
||||||
|
path: "/home/node/.mcp-inspector/secrets.json",
|
||||||
|
looseMode: 0o644,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const band = screen.getByTestId("secret-storage-footer");
|
||||||
|
expect(band).toHaveTextContent("Mode 0644 — not owner-only.");
|
||||||
|
expect(band).toHaveAttribute("data-tone", "warn");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,11 @@ function footerMessage(info: SecretStorageInfo): string {
|
||||||
? `${secretStorageLabel(info)}: ${caveat}`
|
? `${secretStorageLabel(info)}: ${caveat}`
|
||||||
: secretStorageLabel(info);
|
: secretStorageLabel(info);
|
||||||
}
|
}
|
||||||
|
// "Unreadable" rather than guessing a mode: the envelope could not be
|
||||||
|
// parsed, so neither "Plaintext" nor "Encrypted" is a claim we can make.
|
||||||
|
if (info.encryptionUnknown !== undefined) {
|
||||||
|
return "Unreadable file. Saving a secret will fail.";
|
||||||
|
}
|
||||||
const encryption = info.plaintext ? "Plaintext" : "Encrypted";
|
const encryption = info.plaintext ? "Plaintext" : "Encrypted";
|
||||||
const permissions = filePermissionsSentence(info);
|
const permissions = filePermissionsSentence(info);
|
||||||
return `${encryption} file. ${permissions}`;
|
return `${encryption} file. ${permissions}`;
|
||||||
|
|
@ -174,7 +179,11 @@ function footerMessage(info: SecretStorageInfo): string {
|
||||||
function footerTooltip(info: SecretStorageInfo): string | undefined {
|
function footerTooltip(info: SecretStorageInfo): string | undefined {
|
||||||
if (info.kind !== "file" || !info.path) return undefined;
|
if (info.kind !== "file" || !info.path) return undefined;
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (info.plaintext) {
|
if (info.encryptionUnknown !== undefined) {
|
||||||
|
parts.push(
|
||||||
|
`The file could not be read (${info.encryptionUnknown}), so its contents are unknown and saving a secret will fail rather than overwrite it.`,
|
||||||
|
);
|
||||||
|
} else if (info.plaintext) {
|
||||||
// Advice that can actually clear the condition: someone who has already
|
// Advice that can actually clear the condition: someone who has already
|
||||||
// set the passphrase is waiting on the next write, not on themselves.
|
// set the passphrase is waiting on the next write, not on themselves.
|
||||||
parts.push(
|
parts.push(
|
||||||
|
|
@ -184,8 +193,13 @@ function footerTooltip(info: SecretStorageInfo): string | undefined {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (info.looseMode !== undefined) {
|
if (info.looseMode !== undefined) {
|
||||||
|
// Encryption-aware: a reader of an encrypted file gets ciphertext, not
|
||||||
|
// secrets. Claiming otherwise overstates the exposure, and a warning
|
||||||
|
// that cries wolf is discounted on the occasion it is literally true.
|
||||||
parts.push(
|
parts.push(
|
||||||
`Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
|
info.plaintext === false
|
||||||
|
? `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — others can copy the file, and the passphrase is then the only thing protecting its contents.`
|
||||||
|
: `Mode ${formatMode(info.looseMode)} could not be tightened to 0600 — anyone who can read the file can read the secrets in it.`,
|
||||||
);
|
);
|
||||||
} else if (info.permissionsUnknown !== undefined) {
|
} else if (info.permissionsUnknown !== undefined) {
|
||||||
parts.push(
|
parts.push(
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,38 @@ describe("secretStorageCaveat", () => {
|
||||||
expect(secretStorageCaveat(loose)).toContain("0640");
|
expect(secretStorageCaveat(loose)).toContain("0640");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not overstate exposure for an encrypted file with a loose mode", () => {
|
||||||
|
// A reader of an encrypted file gets ciphertext, not secrets. Saying
|
||||||
|
// otherwise trains people to discount the warning on the occasion it is
|
||||||
|
// literally true.
|
||||||
|
const loose: SecretStorageInfo = { ...encryptedFile, looseMode: 0o644 };
|
||||||
|
const caveat = secretStorageCaveat(loose);
|
||||||
|
expect(caveat).toContain("0644");
|
||||||
|
expect(caveat).toContain("passphrase is then the only thing");
|
||||||
|
expect(caveat).not.toContain("can read the secrets in it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the blunt wording for a plaintext file with a loose mode", () => {
|
||||||
|
const loose: SecretStorageInfo = { ...plaintextFile, looseMode: 0o644 };
|
||||||
|
expect(secretStorageCaveat(loose)).toContain("can read the secrets in it");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says a file it could not read is unreadable, and warns about the next save", () => {
|
||||||
|
const unreadable: SecretStorageInfo = {
|
||||||
|
kind: "file",
|
||||||
|
reason: "fallback",
|
||||||
|
durable: true,
|
||||||
|
path: "/x/secrets.json",
|
||||||
|
encryptionUnknown: "not valid JSON",
|
||||||
|
};
|
||||||
|
// Neither "encrypted" nor "unencrypted" is a claim we can make here.
|
||||||
|
expect(secretStorageLabel(unreadable)).toBe("File (unreadable)");
|
||||||
|
expect(secretStorageTone(unreadable)).toBe("warn");
|
||||||
|
expect(secretStorageCaveat(unreadable)).toMatch(
|
||||||
|
/could not be read \(not valid JSON\)\. Saving a secret will fail/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers the memory caveat when a memory store somehow carries the flag", () => {
|
it("prefers the memory caveat when a memory store somehow carries the flag", () => {
|
||||||
// Defensive ordering: `plaintext` is meaningless for memory, and the
|
// Defensive ordering: `plaintext` is meaningless for memory, and the
|
||||||
// durability loss is the more consequential of the two statements.
|
// durability loss is the more consequential of the two statements.
|
||||||
|
|
|
||||||
|
|
@ -296,6 +296,26 @@ describe("useInitialConfig", () => {
|
||||||
expect(result.current.secretStorage).toEqual(info);
|
expect(result.current.secretStorage).toEqual(info);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("passes through a descriptor that reports an unreadable file", async () => {
|
||||||
|
// `plaintext` is legitimately absent here; the guard must accept that
|
||||||
|
// shape rather than requiring a field the backend cannot honestly set.
|
||||||
|
const info = {
|
||||||
|
kind: "file",
|
||||||
|
reason: "fallback",
|
||||||
|
durable: true,
|
||||||
|
path: "/home/node/.mcp-inspector/secrets.json",
|
||||||
|
encryptionUnknown: "not valid JSON",
|
||||||
|
};
|
||||||
|
const fetchFn = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(jsonResponse({ secretStorage: info }));
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useInitialConfig({ baseUrl: "http://test.local", fetchFn }),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.secretStorage).toEqual(info);
|
||||||
|
});
|
||||||
|
|
||||||
it("is undefined on a backend that omits the field", async () => {
|
it("is undefined on a backend that omits the field", async () => {
|
||||||
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
|
const fetchFn = vi.fn().mockResolvedValue(jsonResponse({}));
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
|
|
@ -355,6 +375,33 @@ describe("useInitialConfig", () => {
|
||||||
looseMode: "644",
|
looseMode: "644",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
// Neither half of the encryption question answered.
|
||||||
|
"a file descriptor with neither plaintext nor encryptionUnknown",
|
||||||
|
{ kind: "file", reason: "fallback", durable: true, path: "/x" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Both answered, which is contradictory — the state was either read
|
||||||
|
// or it wasn't.
|
||||||
|
"a file descriptor with both plaintext and encryptionUnknown",
|
||||||
|
{
|
||||||
|
kind: "file",
|
||||||
|
reason: "fallback",
|
||||||
|
durable: true,
|
||||||
|
path: "/x",
|
||||||
|
plaintext: true,
|
||||||
|
encryptionUnknown: "not valid JSON",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"encryptionUnknown on a memory descriptor",
|
||||||
|
{
|
||||||
|
kind: "memory",
|
||||||
|
reason: "fallback",
|
||||||
|
durable: false,
|
||||||
|
encryptionUnknown: "not valid JSON",
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"a non-string permissionsUnknown",
|
"a non-string permissionsUnknown",
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -484,6 +484,33 @@ describe("tightenSecretFilePermissions", () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("names an errno-less stat failure rather than printing undefined", async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock("node:fs/promises", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("node:fs/promises")>(
|
||||||
|
"node:fs/promises",
|
||||||
|
);
|
||||||
|
// No `code` property — a rejection shaped unlike an errno error.
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
default: actual,
|
||||||
|
stat: vi.fn(() => Promise.reject(new Error("something odd"))),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const mod =
|
||||||
|
await import("@inspector/core/auth/node/file-secret-store.js");
|
||||||
|
expect(await mod.readSecretFilePermissions(filePath())).toEqual({
|
||||||
|
state: "unknown",
|
||||||
|
detail: "unknown error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.doUnmock("node:fs/promises");
|
||||||
|
vi.resetModules();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("readSecretFilePermissions reports the mode without changing it", async () => {
|
it("readSecretFilePermissions reports the mode without changing it", async () => {
|
||||||
// The descriptor is rebuilt on every GET /api/config, so the function it
|
// The descriptor is rebuilt on every GET /api/config, so the function it
|
||||||
// calls must not chmod — once per page load, on a file another process
|
// calls must not chmod — once per page load, on a file another process
|
||||||
|
|
@ -560,25 +587,40 @@ describe("readOnDiskEncryption", () => {
|
||||||
passphrase: "hunter2",
|
passphrase: "hunter2",
|
||||||
});
|
});
|
||||||
expect(withKey.encrypted).toBe(true);
|
expect(withKey.encrypted).toBe(true);
|
||||||
expect(await withKey.readOnDiskEncryption()).toBe("none");
|
expect(await withKey.readOnDiskEncryption()).toEqual({
|
||||||
|
state: "plaintext",
|
||||||
|
});
|
||||||
|
|
||||||
await withKey.set("alpha", "env:A", "1");
|
await withKey.set("alpha", "env:A", "1");
|
||||||
expect(await withKey.readOnDiskEncryption()).toBe("aes-256-gcm");
|
expect(await withKey.readOnDiskEncryption()).toEqual({
|
||||||
|
state: "encrypted",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("answers null for an absent, unparseable, or unrecognized file", async () => {
|
it("keeps 'no file yet' apart from 'there is a file and we cannot read it'", async () => {
|
||||||
|
// Collapsing these is what let a passphrase-configured install claim
|
||||||
|
// "Encrypted file" about a corrupt file `set` was about to refuse: the
|
||||||
|
// descriptor falls back to the write policy on `absent`, which is only
|
||||||
|
// honest when the first write really will create the file that way.
|
||||||
const store = new FileSecretStore({ filePath: filePath() });
|
const store = new FileSecretStore({ filePath: filePath() });
|
||||||
expect(await store.readOnDiskEncryption()).toBe(null);
|
expect(await store.readOnDiskEncryption()).toEqual({ state: "absent" });
|
||||||
|
|
||||||
await fs.writeFile(filePath(), "{not json", "utf-8");
|
await fs.writeFile(filePath(), "{not json", "utf-8");
|
||||||
expect(await store.readOnDiskEncryption()).toBe(null);
|
expect(await store.readOnDiskEncryption()).toEqual({
|
||||||
|
state: "unreadable",
|
||||||
|
detail: "not valid JSON",
|
||||||
|
});
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
filePath(),
|
filePath(),
|
||||||
JSON.stringify({ version: 1, encryption: "rot13" }),
|
JSON.stringify({ version: 1, encryption: "rot13" }),
|
||||||
"utf-8",
|
"utf-8",
|
||||||
);
|
);
|
||||||
expect(await store.readOnDiskEncryption()).toBe(null);
|
const unsupported = await store.readOnDiskEncryption();
|
||||||
|
expect(unsupported.state).toBe("unreadable");
|
||||||
|
expect(
|
||||||
|
unsupported.state === "unreadable" ? unsupported.detail : "",
|
||||||
|
).toMatch(/unsupported envelope/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("needs no passphrase — it reads the envelope, never the payload", async () => {
|
it("needs no passphrase — it reads the envelope, never the payload", async () => {
|
||||||
|
|
@ -589,7 +631,9 @@ describe("readOnDiskEncryption", () => {
|
||||||
});
|
});
|
||||||
await writer.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
await writer.set("alpha", SECRET_FIELD_OAUTH_CLIENT_SECRET, "shh");
|
||||||
const keyless = new FileSecretStore({ filePath: filePath() });
|
const keyless = new FileSecretStore({ filePath: filePath() });
|
||||||
expect(await keyless.readOnDiskEncryption()).toBe("aes-256-gcm");
|
expect(await keyless.readOnDiskEncryption()).toEqual({
|
||||||
|
state: "encrypted",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -884,6 +928,51 @@ describe("acquireLock", () => {
|
||||||
await release();
|
await release();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["not JSON at all", "{ not json"],
|
||||||
|
["JSON that is not an object", '"just-a-string"'],
|
||||||
|
["an object with no token", JSON.stringify({ pid: 1, host: "h" })],
|
||||||
|
])("steals a stale lock whose owner stamp is %s", async (_label, stamp) => {
|
||||||
|
// An unusable stamp cannot answer the liveness question, so the mtime is
|
||||||
|
// all there is — and stealing on it is right, because the alternative is
|
||||||
|
// a lock nothing can ever break.
|
||||||
|
const target = path.join(tmpDir, `stamp-${_label.length}.json`);
|
||||||
|
const lockDir = `${target}.lock`;
|
||||||
|
await fs.mkdir(lockDir, { recursive: true });
|
||||||
|
await fs.writeFile(path.join(lockDir, "owner"), stamp, "utf-8");
|
||||||
|
const old = new Date(Date.now() - 60_000);
|
||||||
|
await fs.utimes(lockDir, old, old);
|
||||||
|
|
||||||
|
const release = await acquireLock(target, {
|
||||||
|
staleMs: 50,
|
||||||
|
timeoutMs: 2_000,
|
||||||
|
pollMs: 10,
|
||||||
|
});
|
||||||
|
await release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("steals a stale lock whose stamp carries no usable pid", async () => {
|
||||||
|
// A stamp with a non-numeric pid names no process to ask about, so it
|
||||||
|
// cannot vouch for the holder being alive.
|
||||||
|
const target = path.join(tmpDir, "nopid.json");
|
||||||
|
const lockDir = `${target}.lock`;
|
||||||
|
await fs.mkdir(lockDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(lockDir, "owner"),
|
||||||
|
JSON.stringify({ token: "t", pid: "not-a-number", host: os.hostname() }),
|
||||||
|
"utf-8",
|
||||||
|
);
|
||||||
|
const old = new Date(Date.now() - 60_000);
|
||||||
|
await fs.utimes(lockDir, old, old);
|
||||||
|
|
||||||
|
const release = await acquireLock(target, {
|
||||||
|
staleMs: 50,
|
||||||
|
timeoutMs: 2_000,
|
||||||
|
pollMs: 10,
|
||||||
|
});
|
||||||
|
await release();
|
||||||
|
});
|
||||||
|
|
||||||
it("steals a stale lock stamped by another machine", async () => {
|
it("steals a stale lock stamped by another machine", async () => {
|
||||||
// A shared filesystem: we cannot ask this host about that host's pid, so
|
// A shared filesystem: we cannot ask this host about that host's pid, so
|
||||||
// the mtime is all there is. Refusing would make a lock nothing can ever
|
// the mtime is all there is. Refusing would make a lock nothing can ever
|
||||||
|
|
|
||||||
|
|
@ -634,6 +634,63 @@ describe("absorbFileSecretsIntoKeyring", () => {
|
||||||
expect(await keyring.get("srv", "oauthClientSecret")).toBe(null);
|
expect(await keyring.get("srv", "oauthClientSecret")).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aborts rather than overwriting when the keychain read fails", async () => {
|
||||||
|
// `get` answers null for an unreachable keychain as well as a missing
|
||||||
|
// entry, and the hand-off *writes* on null — so a transient read failure
|
||||||
|
// would replace a newer keychain value with the older file copy, exactly
|
||||||
|
// inverting the keychain-wins rule. The strict read turns that into a
|
||||||
|
// throw, which leaves the file in place for the next run.
|
||||||
|
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
|
||||||
|
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const mod = await loadWithProbe(true);
|
||||||
|
|
||||||
|
let wrote = false;
|
||||||
|
const flaky: SecretStore = {
|
||||||
|
get: async () => null,
|
||||||
|
getStrict: async () => {
|
||||||
|
throw new Error("keychain temporarily unavailable");
|
||||||
|
},
|
||||||
|
set: async () => {
|
||||||
|
wrote = true;
|
||||||
|
},
|
||||||
|
delete: async () => {},
|
||||||
|
deleteAllForServer: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await mod.absorbFileSecretsIntoKeyring(flaky);
|
||||||
|
|
||||||
|
expect(wrote).toBe(false);
|
||||||
|
expect(existsSync(filePath)).toBe(true);
|
||||||
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("left in place"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the strict read when the store offers one", async () => {
|
||||||
|
// And when it answers "present", the keychain still wins.
|
||||||
|
const filePath = await seedFile({ "srv:oauthClientSecret": "from-file" });
|
||||||
|
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const mod = await loadWithProbe(true);
|
||||||
|
|
||||||
|
let wrote = false;
|
||||||
|
const store: SecretStore = {
|
||||||
|
// Deliberately disagrees with `getStrict` so the test proves which one
|
||||||
|
// the migration consults.
|
||||||
|
get: async () => null,
|
||||||
|
getStrict: async () => "already-here",
|
||||||
|
set: async () => {
|
||||||
|
wrote = true;
|
||||||
|
},
|
||||||
|
delete: async () => {},
|
||||||
|
deleteAllForServer: async () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
await mod.absorbFileSecretsIntoKeyring(store);
|
||||||
|
|
||||||
|
expect(wrote).toBe(false);
|
||||||
|
expect(existsSync(filePath)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("is a no-op for a file that holds no entries", async () => {
|
it("is a no-op for a file that holds no entries", async () => {
|
||||||
// An empty map is not a hand-off. Deleting the file here would be
|
// An empty map is not a hand-off. Deleting the file here would be
|
||||||
// harmless but pointless; the interesting part is that it does not warn
|
// harmless but pointless; the interesting part is that it does not warn
|
||||||
|
|
@ -688,6 +745,47 @@ describe("absorbFileSecretsIntoKeyring", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the descriptor refuses to guess an unreadable file's encryption", () => {
|
||||||
|
it("reports encryptionUnknown instead of falling back to the write policy", async () => {
|
||||||
|
// With a passphrase configured, the old fallback claimed "File
|
||||||
|
// (encrypted)" about a file whose envelope had never been parsed — and
|
||||||
|
// which `set` was about to refuse rather than overwrite.
|
||||||
|
const filePath = path.join(tmpDir, "secrets.json");
|
||||||
|
process.env.MCP_INSPECTOR_SECRET_FILE = filePath;
|
||||||
|
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
|
||||||
|
process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2";
|
||||||
|
await fs.writeFile(filePath, "{ not json", "utf-8");
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
|
||||||
|
const mod = await loadWithProbe(false);
|
||||||
|
const info = await mod.getSecretStorageInfo();
|
||||||
|
|
||||||
|
expect(info.encryptionUnknown).toBe("not valid JSON");
|
||||||
|
// No half-answer left for a consumer to read a default out of.
|
||||||
|
expect(info.plaintext).toBeUndefined();
|
||||||
|
expect(info.pendingEncryption).toBeUndefined();
|
||||||
|
|
||||||
|
const { secretStorageLabel, secretStorageCaveat, secretStorageTone } =
|
||||||
|
await import("@inspector/core/auth/secret-storage-info.js");
|
||||||
|
expect(secretStorageLabel(info)).toBe("File (unreadable)");
|
||||||
|
expect(secretStorageTone(info)).toBe("warn");
|
||||||
|
expect(secretStorageCaveat(info)).toMatch(/Saving a secret will fail/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still uses the write policy when there is genuinely no file", async () => {
|
||||||
|
// The control: `absent` is the one state where reporting the policy is
|
||||||
|
// honest, because the first write really will create it that way.
|
||||||
|
process.env.MCP_INSPECTOR_SECRET_FILE = path.join(tmpDir, "none.json");
|
||||||
|
process.env.MCP_INSPECTOR_SECRET_STORE = "file";
|
||||||
|
process.env.MCP_INSPECTOR_SECRET_KEY = "hunter2";
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
|
const mod = await loadWithProbe(false);
|
||||||
|
const info = await mod.getSecretStorageInfo();
|
||||||
|
expect(info.encryptionUnknown).toBeUndefined();
|
||||||
|
expect(info.plaintext).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("the descriptor reports a mode it could not verify", () => {
|
describe("the descriptor reports a mode it could not verify", () => {
|
||||||
it("carries permissionsUnknown when the file exists but cannot be inspected", async () => {
|
it("carries permissionsUnknown when the file exists but cannot be inspected", async () => {
|
||||||
// The third state. Without it an unverifiable mode fell into the same
|
// The third state. Without it an unverifiable mode fell into the same
|
||||||
|
|
|
||||||
|
|
@ -456,7 +456,7 @@ async function canSteal(
|
||||||
} catch {
|
} catch {
|
||||||
// No stamp to consult; fall through to the time-only answer.
|
// No stamp to consult; fall through to the time-only answer.
|
||||||
}
|
}
|
||||||
if (!owner || owner.host !== os.hostname() || typeof owner.pid !== "number") {
|
if (!owner || owner.host !== os.hostname() || owner.pid === undefined) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return !processIsAlive(owner.pid);
|
return !processIsAlive(owner.pid);
|
||||||
|
|
@ -475,7 +475,8 @@ function processIsAlive(pid: number): boolean {
|
||||||
|
|
||||||
interface LockOwner {
|
interface LockOwner {
|
||||||
token: string;
|
token: string;
|
||||||
pid: number;
|
/** Absent when the stamp carried no usable (positive) pid. */
|
||||||
|
pid?: number;
|
||||||
host: string;
|
host: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -487,8 +488,13 @@ function parseOwner(raw: string): LockOwner | null {
|
||||||
const { token, pid, host } = parsed as Partial<LockOwner>;
|
const { token, pid, host } = parsed as Partial<LockOwner>;
|
||||||
if (typeof token !== "string") return null;
|
if (typeof token !== "string") return null;
|
||||||
return {
|
return {
|
||||||
|
// Only a *positive* pid is usable. `-1` and `0` are wildcards to
|
||||||
|
// `process.kill` — `kill(-1, 0)` asks about every process this user
|
||||||
|
// can signal and answers "alive" for almost any host, which would make
|
||||||
|
// a garbled stamp permanently unstealable. Anything else is recorded
|
||||||
|
// as absent so the caller falls back to the mtime.
|
||||||
|
pid: typeof pid === "number" && pid > 0 ? pid : undefined,
|
||||||
token,
|
token,
|
||||||
pid: typeof pid === "number" ? pid : -1,
|
|
||||||
host: String(host),
|
host: String(host),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -496,6 +502,17 @@ function parseOwner(raw: string): LockOwner | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the secrets file's envelope says, or why it could not be read.
|
||||||
|
* `absent` is "nothing written yet"; `unreadable` is "there is a file and
|
||||||
|
* we cannot tell" — two very different things to say to a user.
|
||||||
|
*/
|
||||||
|
export type SecretFileEncryptionState =
|
||||||
|
| { state: "absent" }
|
||||||
|
| { state: "plaintext" }
|
||||||
|
| { state: "encrypted" }
|
||||||
|
| { state: "unreadable"; detail: string };
|
||||||
|
|
||||||
export interface FileSecretStoreOptions {
|
export interface FileSecretStoreOptions {
|
||||||
/** Absolute path of the secrets file. */
|
/** Absolute path of the secrets file. */
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|
@ -550,20 +567,36 @@ export class FileSecretStore implements SecretStore {
|
||||||
* Reads the envelope only. It never decrypts, so it needs no passphrase
|
* Reads the envelope only. It never decrypts, so it needs no passphrase
|
||||||
* and answers correctly even when the key is wrong or missing — which is
|
* and answers correctly even when the key is wrong or missing — which is
|
||||||
* also a state the UI has to describe rather than throw on.
|
* also a state the UI has to describe rather than throw on.
|
||||||
|
*
|
||||||
|
* `absent` and `unreadable` are kept apart deliberately. Collapsing them
|
||||||
|
* (as a bare `null` did) means an existing file that is corrupt, or
|
||||||
|
* written in an envelope this build does not know, is reported as "no
|
||||||
|
* file yet" — and the descriptor then falls back to the configured write
|
||||||
|
* policy, so a passphrase-configured install claims "Encrypted file"
|
||||||
|
* about a file whose encryption state was never established and which
|
||||||
|
* `set` is in fact about to refuse.
|
||||||
*/
|
*/
|
||||||
async readOnDiskEncryption(): Promise<SecretFile["encryption"] | null> {
|
async readOnDiskEncryption(): Promise<SecretFileEncryptionState> {
|
||||||
|
let raw: string | null;
|
||||||
|
try {
|
||||||
|
raw = await readStoreFile(this.filePath);
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
state: "unreadable",
|
||||||
|
detail: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (raw === null) return { state: "absent" };
|
||||||
try {
|
try {
|
||||||
const raw = await readStoreFile(this.filePath);
|
|
||||||
if (raw === null) return null;
|
|
||||||
const parsed = JSON.parse(raw) as SecretFile;
|
const parsed = JSON.parse(raw) as SecretFile;
|
||||||
return parsed.encryption === "none" || parsed.encryption === CIPHER
|
if (parsed.encryption === "none") return { state: "plaintext" };
|
||||||
? parsed.encryption
|
if (parsed.encryption === CIPHER) return { state: "encrypted" };
|
||||||
: null;
|
return {
|
||||||
|
state: "unreadable",
|
||||||
|
detail: `unsupported envelope (encryption="${String(parsed.encryption)}", version=${String(parsed.version)})`,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Absent, unreadable, or not JSON. "Unknown" is the honest answer, and
|
return { state: "unreadable", detail: "not valid JSON" };
|
||||||
// the caller falls back to reporting the configured policy — the only
|
|
||||||
// thing that is knowable about a file nobody can read.
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ import {
|
||||||
KeyringSecretStore,
|
KeyringSecretStore,
|
||||||
parseAccount,
|
parseAccount,
|
||||||
probeKeyringAvailable,
|
probeKeyringAvailable,
|
||||||
|
secretStoreGetStrict,
|
||||||
secretStoreIsDurable,
|
secretStoreIsDurable,
|
||||||
SessionSecretStore,
|
SessionSecretStore,
|
||||||
type SecretStore,
|
type SecretStore,
|
||||||
|
|
@ -334,7 +335,14 @@ async function describeFileStore(
|
||||||
detail?: string,
|
detail?: string,
|
||||||
): Promise<SecretStorageInfo> {
|
): Promise<SecretStorageInfo> {
|
||||||
const onDisk = await store.readOnDiskEncryption();
|
const onDisk = await store.readOnDiskEncryption();
|
||||||
const plaintext = onDisk === null ? !store.encrypted : onDisk === "none";
|
// `absent` is the only state where falling back to the configured write
|
||||||
|
// policy is honest — the first write will create the file in exactly that
|
||||||
|
// mode. `unreadable` must not: the encryption state was never established,
|
||||||
|
// and reporting the policy there would claim "Encrypted file" about a file
|
||||||
|
// `set` is about to refuse.
|
||||||
|
const unreadable = onDisk.state === "unreadable" ? onDisk.detail : undefined;
|
||||||
|
const plaintext =
|
||||||
|
onDisk.state === "absent" ? !store.encrypted : onDisk.state === "plaintext";
|
||||||
// Re-checked here rather than captured at selection, for the same reason
|
// Re-checked here rather than captured at selection, for the same reason
|
||||||
// the encryption state is: the mode can change under a running process,
|
// the encryption state is: the mode can change under a running process,
|
||||||
// and this descriptor is what the browser renders as fact. The *read-only*
|
// and this descriptor is what the browser renders as fact. The *read-only*
|
||||||
|
|
@ -346,12 +354,17 @@ async function describeFileStore(
|
||||||
kind,
|
kind,
|
||||||
reason,
|
reason,
|
||||||
path: filePath,
|
path: filePath,
|
||||||
plaintext,
|
// Omitted entirely when the envelope could not be read, so no consumer
|
||||||
|
// can read a default out of it.
|
||||||
|
...(unreadable === undefined ? { plaintext } : {}),
|
||||||
|
...(unreadable !== undefined ? { encryptionUnknown: unreadable } : {}),
|
||||||
...(perms.state === "loose" ? { looseMode: perms.mode } : {}),
|
...(perms.state === "loose" ? { looseMode: perms.mode } : {}),
|
||||||
...(perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {}),
|
...(perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {}),
|
||||||
// Only meaningful while the two disagree; omitted otherwise so the
|
// Only meaningful while the two disagree; omitted otherwise so the
|
||||||
// payload does not carry a flag that says nothing.
|
// payload does not carry a flag that says nothing.
|
||||||
...(plaintext && store.encrypted ? { pendingEncryption: true } : {}),
|
...(unreadable === undefined && plaintext && store.encrypted
|
||||||
|
? { pendingEncryption: true }
|
||||||
|
: {}),
|
||||||
durable: true,
|
durable: true,
|
||||||
detail,
|
detail,
|
||||||
};
|
};
|
||||||
|
|
@ -481,7 +494,17 @@ export async function absorbFileSecretsIntoKeyring(
|
||||||
for (const [account, value] of Object.entries(entries)) {
|
for (const [account, value] of Object.entries(entries)) {
|
||||||
const parsed = parseAccount(account);
|
const parsed = parseAccount(account);
|
||||||
if (!parsed) continue;
|
if (!parsed) continue;
|
||||||
const existing = await keyring.get(parsed.serverId, parsed.field);
|
// Strict: `get` answers `null` for an unreadable keychain as well as
|
||||||
|
// for a missing entry, and this branch *writes* on `null`. A transient
|
||||||
|
// read failure would therefore overwrite a newer keychain value with
|
||||||
|
// the older file copy — the exact inversion of the keychain-wins rule
|
||||||
|
// this migration is built on. A throw here aborts the hand-off and
|
||||||
|
// leaves the file in place, so the next run retries.
|
||||||
|
const existing = await secretStoreGetStrict(
|
||||||
|
keyring,
|
||||||
|
parsed.serverId,
|
||||||
|
parsed.field,
|
||||||
|
);
|
||||||
if (existing === null) {
|
if (existing === null) {
|
||||||
await keyring.set(parsed.serverId, parsed.field, value);
|
await keyring.set(parsed.serverId, parsed.field, value);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,23 @@ export interface SecretStore {
|
||||||
*/
|
*/
|
||||||
isDurable?(): Promise<boolean>;
|
isDurable?(): Promise<boolean>;
|
||||||
get(serverId: string, field: string): Promise<string | null>;
|
get(serverId: string, field: string): Promise<string | null>;
|
||||||
|
/**
|
||||||
|
* Like {@link get}, but **throws** when the store cannot be read instead
|
||||||
|
* of answering `null`.
|
||||||
|
*
|
||||||
|
* `get` is deliberately tolerant — an unreachable keychain and an absent
|
||||||
|
* entry are the same answer to a caller that just wants to render a form.
|
||||||
|
* That tolerance is wrong for exactly one caller: a migration that treats
|
||||||
|
* `null` as proof of absence and then *writes*. A transient read failure
|
||||||
|
* would look like "nothing there", and the write would overwrite a newer
|
||||||
|
* keychain value with an older file copy — silently inverting the
|
||||||
|
* keychain-wins rule the migration exists to honor.
|
||||||
|
*
|
||||||
|
* Optional, so existing implementations (and test doubles) keep
|
||||||
|
* compiling; {@link secretStoreGetStrict} falls back to `get` when it is
|
||||||
|
* absent, which is correct for stores whose reads cannot fail.
|
||||||
|
*/
|
||||||
|
getStrict?(serverId: string, field: string): Promise<string | null>;
|
||||||
set(serverId: string, field: string, value: string): Promise<void>;
|
set(serverId: string, field: string, value: string): Promise<void>;
|
||||||
/** No-op if no entry exists. */
|
/** No-op if no entry exists. */
|
||||||
delete(serverId: string, field: string): Promise<void>;
|
delete(serverId: string, field: string): Promise<void>;
|
||||||
|
|
@ -370,6 +387,21 @@ export interface SecretStore {
|
||||||
* one the first version of this contract actually handled.
|
* one the first version of this contract actually handled.
|
||||||
*/
|
*/
|
||||||
export class KeyringSecretStore implements SecretStore {
|
export class KeyringSecretStore implements SecretStore {
|
||||||
|
/**
|
||||||
|
* The intolerant read. Same lookup as {@link get}, minus the catch — a
|
||||||
|
* keychain that cannot answer must not be reported as an empty one to a
|
||||||
|
* caller that is about to write based on the answer.
|
||||||
|
*/
|
||||||
|
async getStrict(serverId: string, field: string): Promise<string | null> {
|
||||||
|
const keyring = await loadKeyring();
|
||||||
|
if (!keyring.ok) throw new KeychainUnavailableError(keyring.err);
|
||||||
|
const entry = new keyring.mod.AsyncEntry(
|
||||||
|
SERVICE_NAME,
|
||||||
|
buildAccount(serverId, field),
|
||||||
|
);
|
||||||
|
return (await entry.getPassword()) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
async get(serverId: string, field: string): Promise<string | null> {
|
async get(serverId: string, field: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const keyring = await loadKeyring();
|
const keyring = await loadKeyring();
|
||||||
|
|
@ -466,6 +498,22 @@ export class KeyringSecretStore implements SecretStore {
|
||||||
* other way would make every double silently non-durable and quietly
|
* other way would make every double silently non-durable and quietly
|
||||||
* disable the migrations they exist to exercise.
|
* disable the migrations they exist to exercise.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Read `store` intolerantly: throw rather than answer `null` when the store
|
||||||
|
* itself cannot be read. Falls back to `get` for a store that does not
|
||||||
|
* distinguish the two, which is right for the in-memory and file stores —
|
||||||
|
* neither can fail in a way that masquerades as absence.
|
||||||
|
*/
|
||||||
|
export async function secretStoreGetStrict(
|
||||||
|
store: SecretStore,
|
||||||
|
serverId: string,
|
||||||
|
field: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
return store.getStrict
|
||||||
|
? store.getStrict(serverId, field)
|
||||||
|
: store.get(serverId, field);
|
||||||
|
}
|
||||||
|
|
||||||
export async function secretStoreIsDurable(
|
export async function secretStoreIsDurable(
|
||||||
store: SecretStore,
|
store: SecretStore,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,18 @@ export interface SecretStorageInfo {
|
||||||
* mode 0600 as fact having verified nothing.
|
* mode 0600 as fact having verified nothing.
|
||||||
*/
|
*/
|
||||||
permissionsUnknown?: string;
|
permissionsUnknown?: string;
|
||||||
|
/**
|
||||||
|
* Set when the secrets file exists but its envelope could not be read —
|
||||||
|
* corrupt JSON, or a format written by a newer Inspector. Carries the
|
||||||
|
* reason.
|
||||||
|
*
|
||||||
|
* Mutually exclusive with a meaningful {@link plaintext}: the whole point
|
||||||
|
* is that the encryption state was never established, so neither "File
|
||||||
|
* (encrypted)" nor "File (unencrypted)" may be claimed. `set` will refuse
|
||||||
|
* such a file rather than overwrite it, so this is also a live warning
|
||||||
|
* about writes, not only a description.
|
||||||
|
*/
|
||||||
|
encryptionUnknown?: string;
|
||||||
/**
|
/**
|
||||||
* True when secrets outlive the process. False only for `memory`, and
|
* True when secrets outlive the process. False only for `memory`, and
|
||||||
* it is the *promise* being made rather than an implementation detail:
|
* it is the *promise* being made rather than an implementation detail:
|
||||||
|
|
@ -106,6 +118,7 @@ export function secretStorageLabel(info: SecretStorageInfo): string {
|
||||||
case "keyring":
|
case "keyring":
|
||||||
return "OS keychain";
|
return "OS keychain";
|
||||||
case "file":
|
case "file":
|
||||||
|
if (info.encryptionUnknown !== undefined) return "File (unreadable)";
|
||||||
return info.plaintext ? "File (unencrypted)" : "File (encrypted)";
|
return info.plaintext ? "File (unencrypted)" : "File (encrypted)";
|
||||||
case "memory":
|
case "memory":
|
||||||
return "Memory (this session only)";
|
return "Memory (this session only)";
|
||||||
|
|
@ -130,6 +143,9 @@ export function secretStorageTone(info: SecretStorageInfo): SecretStorageTone {
|
||||||
// Unverified is not the same as fine. The quiet tone here would read as a
|
// Unverified is not the same as fine. The quiet tone here would read as a
|
||||||
// confirmation we never made.
|
// confirmation we never made.
|
||||||
if (info.permissionsUnknown !== undefined) return "warn";
|
if (info.permissionsUnknown !== undefined) return "warn";
|
||||||
|
// A file we cannot read is one we cannot write either — a warning about
|
||||||
|
// the next save, not just about the past.
|
||||||
|
if (info.encryptionUnknown !== undefined) return "warn";
|
||||||
return "neutral";
|
return "neutral";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -149,7 +165,13 @@ export function secretStorageCaveat(
|
||||||
// property of a file only its owner can open.
|
// property of a file only its owner can open.
|
||||||
if (info.looseMode !== undefined) {
|
if (info.looseMode !== undefined) {
|
||||||
const mode = info.looseMode.toString(8).padStart(4, "0");
|
const mode = info.looseMode.toString(8).padStart(4, "0");
|
||||||
return `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`;
|
// The consequence differs by encryption, and overstating it is its own
|
||||||
|
// kind of wrong: a reader of an *encrypted* file gets ciphertext, not
|
||||||
|
// secrets. Saying otherwise trains people to discount the warning on the
|
||||||
|
// occasion it is literally true.
|
||||||
|
return info.plaintext === false
|
||||||
|
? `The secrets file is mode ${mode}, not 0600, and could not be tightened — others can copy it, and the passphrase is then the only thing protecting its contents.`
|
||||||
|
: `The secrets file is mode ${mode}, not 0600, and could not be tightened — anyone who can read it can read the secrets in it.`;
|
||||||
}
|
}
|
||||||
// Ordered with the permission problems, above encryption, for the same
|
// Ordered with the permission problems, above encryption, for the same
|
||||||
// reason: not knowing whether others can read the file is a question about
|
// reason: not knowing whether others can read the file is a question about
|
||||||
|
|
@ -157,6 +179,11 @@ export function secretStorageCaveat(
|
||||||
if (info.permissionsUnknown !== undefined) {
|
if (info.permissionsUnknown !== undefined) {
|
||||||
return `The secrets file's permissions could not be checked (${info.permissionsUnknown}), so it is not known whether others can read it.`;
|
return `The secrets file's permissions could not be checked (${info.permissionsUnknown}), so it is not known whether others can read it.`;
|
||||||
}
|
}
|
||||||
|
// An unreadable envelope outranks the encryption caveats below, which all
|
||||||
|
// presuppose we know which mode the file is in.
|
||||||
|
if (info.encryptionUnknown !== undefined) {
|
||||||
|
return `The secrets file could not be read (${info.encryptionUnknown}). Saving a secret will fail rather than overwrite it.`;
|
||||||
|
}
|
||||||
if (info.plaintext) {
|
if (info.plaintext) {
|
||||||
return info.pendingEncryption
|
return info.pendingEncryption
|
||||||
? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved."
|
? "Existing secrets in this file are still unencrypted (file mode 0600). They are re-encrypted the next time a secret is saved."
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,16 @@ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined {
|
||||||
if (typeof v.durable !== "boolean") return undefined;
|
if (typeof v.durable !== "boolean") return undefined;
|
||||||
if (v.kind === "file") {
|
if (v.kind === "file") {
|
||||||
if (typeof v.path !== "string" || v.path === "") return undefined;
|
if (typeof v.path !== "string" || v.path === "") return undefined;
|
||||||
if (typeof v.plaintext !== "boolean") return undefined;
|
// Exactly one of the two must be present: either we know the encryption
|
||||||
|
// state, or we know we could not read it. A descriptor carrying neither
|
||||||
|
// (or both) was not built by a backend we understand, and the footer
|
||||||
|
// would have to invent the missing half.
|
||||||
|
const knowsEncryption = typeof v.plaintext === "boolean";
|
||||||
|
const knowsItCannotRead = typeof v.encryptionUnknown === "string";
|
||||||
|
if (knowsEncryption === knowsItCannotRead) return undefined;
|
||||||
|
if (v.encryptionUnknown !== undefined && !knowsItCannotRead) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
// Optional, so absent is fine — but a *present* value must be a real
|
// Optional, so absent is fine — but a *present* value must be a real
|
||||||
// boolean. `pendingEncryption: "false"` is a truthy string, and the
|
// boolean. `pendingEncryption: "false"` is a truthy string, and the
|
||||||
// footer would read it as "already re-encrypting" and print advice that
|
// footer would read it as "already re-encrypting" and print advice that
|
||||||
|
|
@ -125,7 +134,8 @@ function usableSecretStorage(value: unknown): SecretStorageInfo | undefined {
|
||||||
v.plaintext !== undefined ||
|
v.plaintext !== undefined ||
|
||||||
v.pendingEncryption !== undefined ||
|
v.pendingEncryption !== undefined ||
|
||||||
v.looseMode !== undefined ||
|
v.looseMode !== undefined ||
|
||||||
v.permissionsUnknown !== undefined
|
v.permissionsUnknown !== undefined ||
|
||||||
|
v.encryptionUnknown !== undefined
|
||||||
) {
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue