fix(secrets): key placeholder base on exact secret value to block case-hint sibling synthesis
This commit is contained in:
+1
-1
@@ -80,7 +80,7 @@ Each entry in the array has these fields:
|
||||
|
||||
This produces placeholders shaped like `#GITHUBTOKEN_AB12:L#`. The friendly name is sanitized to uppercase letters and digits, capped at 32 characters, and omitted if it sanitizes to an empty value. Invalid optional `friendlyName` metadata does not disable the secret entry; the secret still obfuscates with an unlabeled placeholder.
|
||||
|
||||
The hash base is an HMAC of the secret under a private per-install key (stored at `~/.omp/secret-placeholder.key`, never sent to a model), so a transcript reader cannot dictionary the placeholder back to the secret. For non-mixed casing the key is the case-folded value, so casing variants share a base distinguished by a case hint suffix:
|
||||
The hash base is an HMAC of the secret under a private per-install key (stored at `~/.omp/secret-placeholder.key`, never sent to a model), so a transcript reader cannot dictionary the placeholder back to the secret. The base is keyed on the exact secret value, so two secrets that differ only by case get independent bases and a provider that sees one placeholder cannot synthesize another secret's token by swapping the hint. A case hint suffix labels the casing of the redacted value for the model:
|
||||
|
||||
| Hint | Meaning |
|
||||
| ---- | -------------------------------------------- |
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
- Added `friendlyName` support for hidden secrets so model-visible placeholders can carry sanitized semantic labels, content-derived hashes, and case hints while preserving exact deobfuscation ([#2465](https://github.com/can1357/oh-my-pi/issues/2465)).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed reversible secret placeholders sharing a case-folded hash base across ASCII case variants, which let a prompt-injected model synthesize a never-provider-visible sibling secret's keyed token by swapping the case hint (`#…:L#` → `#…:U#`) in a tool-call argument. Placeholder bases are now keyed on the exact secret value, so each casing variant gets an independent base and a synthesized sibling token deobfuscates to nothing on live provider/tool-call paths ([#2465](https://github.com/can1357/oh-my-pi/issues/2465)).
|
||||
|
||||
## [16.1.3] - 2026-06-19
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -76,15 +76,6 @@ export function sanitizeSecretFriendlyName(name: string): string | undefined {
|
||||
return sanitized.length > 0 ? sanitized : undefined;
|
||||
}
|
||||
|
||||
function normalizePlaceholderSecret(secret: string): string {
|
||||
// Fold ONLY ASCII A–Z casing — that is exactly what a case hint (U/L/C) can
|
||||
// reconstruct. `String.toLowerCase()` also folds Unicode (e.g. `Ä`→`ä`), which
|
||||
// no hint encodes, so two Unicode-case-distinct secrets that share an ASCII
|
||||
// hint would collapse onto one base key and let a persisted placeholder
|
||||
// deobfuscate to the wrong secret when the secret set or its order changes.
|
||||
return secret.replace(/[A-Z]/g, ch => ch.toLowerCase());
|
||||
}
|
||||
|
||||
// Derive the model-visible base from a KEYED digest of the secret. xxHash is
|
||||
// fast and unkeyed, so a fixed-seed content hash of a low-entropy secret could
|
||||
// be dictionaried from the transcript; HMAC-SHA256 under a private per-install
|
||||
@@ -433,12 +424,15 @@ export class SecretObfuscator {
|
||||
|
||||
#createPlaceholder(secret: string, friendlyName?: string, recursive: boolean = false): string {
|
||||
const hint = inferCaseHint(secret);
|
||||
// `:M` does not encode the exact case pattern, so two distinct mixed-case
|
||||
// values can share a case-folded base + hint. Key those on the exact value
|
||||
// so each token stays stable per value regardless of config/env ordering.
|
||||
// U/L/C/none reconstruct casing from the hint, so they safely share the
|
||||
// case-folded base across casing variants.
|
||||
const baseKey = hint === "M" ? secret : normalizePlaceholderSecret(secret);
|
||||
// Key the base on the EXACT secret value, never a case-folded form. The
|
||||
// case hint is only a model-visible label. If two distinct secrets that
|
||||
// differ solely by ASCII case shared one case-folded base, a provider that
|
||||
// saw one placeholder could swap the hint to synthesize the sibling
|
||||
// secret's keyed token, and live deobfuscation (provider output / tool-call
|
||||
// args) would restore a value that was never provider-visible. Exact-value
|
||||
// keying gives every secret an independent base, so a sibling token cannot
|
||||
// be derived without the per-install key.
|
||||
const baseKey = secret;
|
||||
const sanitizedFriendlyName = friendlyName ? sanitizeSecretFriendlyName(friendlyName) : undefined;
|
||||
const preferredBase = this.#resolvePreferredPlaceholderBase(baseKey);
|
||||
const preferredPlaceholder = buildPlaceholder(hint, preferredBase, sanitizedFriendlyName);
|
||||
|
||||
@@ -716,7 +716,7 @@ describe("SecretObfuscator friendlyName placeholders", () => {
|
||||
expect(stripPendingSecretPlaceholderSuffix("before #TOKEN ")).toBe("before #TOKEN ");
|
||||
});
|
||||
|
||||
it("shares a base hash across casing variants with distinct hints", () => {
|
||||
it("uses independent bases across casing variants with distinct hints", () => {
|
||||
const obfuscator = new SecretObfuscator([
|
||||
{ type: "plain", content: "secret", friendlyName: "token" },
|
||||
{ type: "plain", content: "SECRET", friendlyName: "token" },
|
||||
@@ -728,13 +728,49 @@ describe("SecretObfuscator friendlyName placeholders", () => {
|
||||
const bases = tokens.map(token => /^#TOKEN_([A-Z0-9]+):/.exec(token)?.[1]);
|
||||
|
||||
expect(tokens).toHaveLength(3);
|
||||
expect(new Set(bases).size).toBe(1);
|
||||
// Distinct ASCII-case variants must NOT share a base: a shared case-folded
|
||||
// base would let a provider synthesize a sibling token by swapping the hint.
|
||||
expect(new Set(bases).size).toBe(3);
|
||||
expect(tokens[0]?.endsWith(":L#")).toBe(true);
|
||||
expect(tokens[1]?.endsWith(":U#")).toBe(true);
|
||||
expect(tokens[2]?.endsWith(":C#")).toBe(true);
|
||||
expect(obfuscator.deobfuscate(obfuscated)).toBe("secret SECRET Secret");
|
||||
});
|
||||
|
||||
it("does not restore a case-variant sibling synthesized by swapping the hint", () => {
|
||||
// P1: two obfuscate-mode secrets differing only by ASCII case. Only the
|
||||
// lowercase one is ever provider-visible; a prompt-injected model must not
|
||||
// recover the uppercase secret (never emitted) by taking the visible
|
||||
// token's base and swapping the case hint in a tool-call argument.
|
||||
const key = "case-variant-install-key-0000000000000000000";
|
||||
const obfuscator = new SecretObfuscator(
|
||||
[
|
||||
{ type: "plain", content: "abc12345" },
|
||||
{ type: "plain", content: "ABC12345" },
|
||||
],
|
||||
key,
|
||||
);
|
||||
|
||||
// Provider sees only the lowercase placeholder.
|
||||
const visible = obfuscator.obfuscate("abc12345");
|
||||
expect(visible).toMatch(/^#[A-Z0-9]+:L#$/);
|
||||
const base = /^#([A-Z0-9]+):L#$/.exec(visible)?.[1];
|
||||
if (!base) throw new Error("expected a lowercase placeholder base");
|
||||
|
||||
// The uppercase secret's real token uses an independent base.
|
||||
const upperReal = obfuscator.obfuscate("ABC12345");
|
||||
expect(upperReal).toMatch(/^#[A-Z0-9]+:U#$/);
|
||||
expect(upperReal).not.toBe(`#${base}:U#`);
|
||||
|
||||
// Live deobfuscation of the synthesized sibling token leaves it literal
|
||||
// instead of restoring the never-provider-visible uppercase secret.
|
||||
const synthesized = `#${base}:U#`;
|
||||
expect(obfuscator.deobfuscate(synthesized)).toBe(synthesized);
|
||||
expect(obfuscator.deobfuscateObject({ cmd: synthesized })).toEqual({ cmd: synthesized });
|
||||
// The legitimate visible token still round-trips.
|
||||
expect(obfuscator.deobfuscate(visible)).toBe("abc12345");
|
||||
});
|
||||
|
||||
it("gives duplicate mixed-case variants distinct placeholders", () => {
|
||||
const obfuscator = new SecretObfuscator([
|
||||
{ type: "plain", content: "SeCret", friendlyName: "token" },
|
||||
|
||||
Reference in New Issue
Block a user