@centient/secrets 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +424 -8
  2. package/dist/audit/hmac-chain.d.ts +117 -0
  3. package/dist/audit/hmac-chain.d.ts.map +1 -0
  4. package/dist/audit/hmac-chain.js +811 -0
  5. package/dist/audit/hmac-chain.js.map +1 -0
  6. package/dist/audit/ocsf.d.ts +108 -0
  7. package/dist/audit/ocsf.d.ts.map +1 -0
  8. package/dist/audit/ocsf.js +204 -0
  9. package/dist/audit/ocsf.js.map +1 -0
  10. package/dist/cli/hidden-prompt.d.ts.map +1 -1
  11. package/dist/cli/hidden-prompt.js +55 -10
  12. package/dist/cli/hidden-prompt.js.map +1 -1
  13. package/dist/cli/messages.d.ts +40 -8
  14. package/dist/cli/messages.d.ts.map +1 -1
  15. package/dist/cli/messages.js +34 -8
  16. package/dist/cli/messages.js.map +1 -1
  17. package/dist/cli/secrets-cli.d.ts.map +1 -1
  18. package/dist/cli/secrets-cli.js +191 -32
  19. package/dist/cli/secrets-cli.js.map +1 -1
  20. package/dist/client.d.ts +26 -0
  21. package/dist/client.d.ts.map +1 -0
  22. package/dist/client.js +129 -0
  23. package/dist/client.js.map +1 -0
  24. package/dist/compatibility.d.ts +199 -0
  25. package/dist/compatibility.d.ts.map +1 -0
  26. package/dist/compatibility.js +419 -0
  27. package/dist/compatibility.js.map +1 -0
  28. package/dist/crypto/darwin-keychain.d.ts +394 -0
  29. package/dist/crypto/darwin-keychain.d.ts.map +1 -0
  30. package/dist/crypto/darwin-keychain.js +793 -0
  31. package/dist/crypto/darwin-keychain.js.map +1 -0
  32. package/dist/crypto/vault-common.d.ts +55 -12
  33. package/dist/crypto/vault-common.d.ts.map +1 -1
  34. package/dist/crypto/vault-common.js +74 -71
  35. package/dist/crypto/vault-common.js.map +1 -1
  36. package/dist/environment/EnvironmentManager.d.ts.map +1 -1
  37. package/dist/environment/EnvironmentManager.js +5 -10
  38. package/dist/environment/EnvironmentManager.js.map +1 -1
  39. package/dist/index.d.ts +37 -9
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +38 -6
  42. package/dist/index.js.map +1 -1
  43. package/dist/key-providers/keychain-provider.d.ts +30 -2
  44. package/dist/key-providers/keychain-provider.d.ts.map +1 -1
  45. package/dist/key-providers/keychain-provider.js +29 -5
  46. package/dist/key-providers/keychain-provider.js.map +1 -1
  47. package/dist/key-providers/onepassword-provider.d.ts +65 -5
  48. package/dist/key-providers/onepassword-provider.d.ts.map +1 -1
  49. package/dist/key-providers/onepassword-provider.js +159 -67
  50. package/dist/key-providers/onepassword-provider.js.map +1 -1
  51. package/dist/key-providers/op-cli.d.ts +78 -0
  52. package/dist/key-providers/op-cli.d.ts.map +1 -0
  53. package/dist/key-providers/op-cli.js +123 -0
  54. package/dist/key-providers/op-cli.js.map +1 -0
  55. package/dist/key-providers/passphrase-provider.d.ts +20 -0
  56. package/dist/key-providers/passphrase-provider.d.ts.map +1 -1
  57. package/dist/key-providers/passphrase-provider.js +49 -0
  58. package/dist/key-providers/passphrase-provider.js.map +1 -1
  59. package/dist/key-providers/probe.d.ts +46 -0
  60. package/dist/key-providers/probe.d.ts.map +1 -0
  61. package/dist/key-providers/probe.js +70 -0
  62. package/dist/key-providers/probe.js.map +1 -0
  63. package/dist/key-providers/types.d.ts +51 -1
  64. package/dist/key-providers/types.d.ts.map +1 -1
  65. package/dist/platform/agent-detect.d.ts +39 -3
  66. package/dist/platform/agent-detect.d.ts.map +1 -1
  67. package/dist/platform/agent-detect.js +55 -9
  68. package/dist/platform/agent-detect.js.map +1 -1
  69. package/dist/platform/session-context.d.ts +77 -0
  70. package/dist/platform/session-context.d.ts.map +1 -0
  71. package/dist/platform/session-context.js +83 -0
  72. package/dist/platform/session-context.js.map +1 -0
  73. package/dist/policies/audit-trail.d.ts +25 -0
  74. package/dist/policies/audit-trail.d.ts.map +1 -0
  75. package/dist/policies/audit-trail.js +31 -0
  76. package/dist/policies/audit-trail.js.map +1 -0
  77. package/dist/secret-state.d.ts +54 -0
  78. package/dist/secret-state.d.ts.map +1 -0
  79. package/dist/secret-state.js +2 -0
  80. package/dist/secret-state.js.map +1 -0
  81. package/dist/sinks/index.d.ts +9 -0
  82. package/dist/sinks/index.d.ts.map +1 -0
  83. package/dist/sinks/index.js +5 -0
  84. package/dist/sinks/index.js.map +1 -0
  85. package/dist/sinks/jsonl.d.ts +16 -0
  86. package/dist/sinks/jsonl.d.ts.map +1 -0
  87. package/dist/sinks/jsonl.js +51 -0
  88. package/dist/sinks/jsonl.js.map +1 -0
  89. package/dist/sinks/otel.d.ts +22 -0
  90. package/dist/sinks/otel.d.ts.map +1 -0
  91. package/dist/sinks/otel.js +155 -0
  92. package/dist/sinks/otel.js.map +1 -0
  93. package/dist/sinks/queue.d.ts +12 -0
  94. package/dist/sinks/queue.d.ts.map +1 -0
  95. package/dist/sinks/queue.js +89 -0
  96. package/dist/sinks/queue.js.map +1 -0
  97. package/dist/sinks/syslog.d.ts +17 -0
  98. package/dist/sinks/syslog.d.ts.map +1 -0
  99. package/dist/sinks/syslog.js +103 -0
  100. package/dist/sinks/syslog.js.map +1 -0
  101. package/dist/sinks/types.d.ts +27 -0
  102. package/dist/sinks/types.d.ts.map +1 -0
  103. package/dist/sinks/types.js +18 -0
  104. package/dist/sinks/types.js.map +1 -0
  105. package/dist/vault/policy.d.ts +64 -34
  106. package/dist/vault/policy.d.ts.map +1 -1
  107. package/dist/vault/policy.js +109 -67
  108. package/dist/vault/policy.js.map +1 -1
  109. package/dist/vault/session-vault-errors.d.ts +72 -4
  110. package/dist/vault/session-vault-errors.d.ts.map +1 -1
  111. package/dist/vault/session-vault-errors.js +95 -4
  112. package/dist/vault/session-vault-errors.js.map +1 -1
  113. package/dist/vault/session-vault.d.ts +145 -2
  114. package/dist/vault/session-vault.d.ts.map +1 -1
  115. package/dist/vault/session-vault.js +548 -72
  116. package/dist/vault/session-vault.js.map +1 -1
  117. package/dist/vault/sidecar.d.ts +9 -0
  118. package/dist/vault/sidecar.d.ts.map +1 -1
  119. package/dist/vault/sidecar.js +72 -3
  120. package/dist/vault/sidecar.js.map +1 -1
  121. package/dist/vault/types.d.ts +58 -10
  122. package/dist/vault/types.d.ts.map +1 -1
  123. package/dist/vault/types.js.map +1 -1
  124. package/dist/vault/vault-env.d.ts +43 -50
  125. package/dist/vault/vault-env.d.ts.map +1 -1
  126. package/dist/vault/vault-env.js +141 -62
  127. package/dist/vault/vault-env.js.map +1 -1
  128. package/dist/vault/vault-gpg.d.ts +6 -1
  129. package/dist/vault/vault-gpg.d.ts.map +1 -1
  130. package/dist/vault/vault-gpg.js +10 -8
  131. package/dist/vault/vault-gpg.js.map +1 -1
  132. package/dist/vault/vault-libsecret.d.ts +6 -1
  133. package/dist/vault/vault-libsecret.d.ts.map +1 -1
  134. package/dist/vault/vault-libsecret.js +16 -14
  135. package/dist/vault/vault-libsecret.js.map +1 -1
  136. package/dist/vault/vault-onepassword.d.ts +119 -0
  137. package/dist/vault/vault-onepassword.d.ts.map +1 -0
  138. package/dist/vault/vault-onepassword.js +271 -0
  139. package/dist/vault/vault-onepassword.js.map +1 -0
  140. package/dist/vault/vault-utils.d.ts +37 -3
  141. package/dist/vault/vault-utils.d.ts.map +1 -1
  142. package/dist/vault/vault-utils.js +65 -3
  143. package/dist/vault/vault-utils.js.map +1 -1
  144. package/dist/vault/vault-windows.d.ts +7 -2
  145. package/dist/vault/vault-windows.d.ts.map +1 -1
  146. package/dist/vault/vault-windows.js +21 -16
  147. package/dist/vault/vault-windows.js.map +1 -1
  148. package/dist/vault/vault.d.ts +120 -9
  149. package/dist/vault/vault.d.ts.map +1 -1
  150. package/dist/vault/vault.js +222 -139
  151. package/dist/vault/vault.js.map +1 -1
  152. package/package.json +8 -2
@@ -8,32 +8,92 @@
8
8
  * 4. GpgVault — GPG-encrypted files (Linux / WSL)
9
9
  * 5. EnvVault — Environment variable fallback (always available)
10
10
  *
11
- * The active backend is selected once at module load by calling each backend's
11
+ * The default backend is selected lazily on first use by calling each backend's
12
12
  * static `detect()` method in order and choosing the first one that returns true.
13
+ * A successful selection is then reused for the lifetime of this module.
14
+ *
15
+ * **Explicit selection (ADR-004) sits in front of that cascade.** `secrets.backend`
16
+ * (or `CENTIENT_SECRETS_BACKEND`) names a backend directly and fails closed if it
17
+ * is unusable — it is never silently substituted. `OnePasswordVault` is reachable
18
+ * *only* this way: having `op` installed is not consent to route credentials into
19
+ * someone's 1Password vault.
13
20
  *
14
21
  * Session TTL: 4 hours from the last successful read/write.
15
22
  *
16
- * Error handling: all functions return null/false on failure never throw.
23
+ * Error handling: a *storage* failure is reported as `null`/`false`the
24
+ * backend contract is non-throwing. Three things do throw, because they are
25
+ * not storage outcomes: a policy `before` hook rejecting the operation
26
+ * (ADR-002 §1.0.0), a backend surfacing an unexpected transport failure from
27
+ * `retrieve`/`listKeys`, and a malformed credential key
28
+ * (`InvalidCredentialKeyError`, see the key-validation section below).
17
29
  */
18
- import type { VaultType } from "./types.js";
30
+ import type { KeychainDeps } from "../crypto/darwin-keychain.js";
31
+ import type { SecretState } from "../secret-state.js";
32
+ import type { VaultBackend, VaultType } from "./types.js";
19
33
  /**
20
- * Returns true if the in-process session is still within the TTL window.
21
- * This does NOT validate the token itself — use validateToken() for that.
34
+ * The keychain service every credential VALUE lives under. Exported because
35
+ * the migration helper (`migrateLoginCredentialToSystem`) and operator
36
+ * tooling have to name the same service — a second literal somewhere else is
37
+ * how a migration silently moves nothing.
22
38
  */
23
- export declare function isSessionValid(): boolean;
39
+ export declare const AUTH_KEYCHAIN_SERVICE = "centient-auth";
40
+ /**
41
+ * Wraps the macOS Keychain helper functions as a VaultBackend.
42
+ *
43
+ * Every method routes through `crypto/darwin-keychain.ts` (#188), so credential
44
+ * values inherit explicit System-keychain reads in a sessionless context,
45
+ * `-U` replace-in-place writes and `-T /usr/bin/security` reader
46
+ * preauthorization — the same posture the KeyProvider layer gets for the vault
47
+ * master key.
48
+ *
49
+ * Only available on macOS (darwin).
50
+ */
51
+ export declare class KeychainVault implements VaultBackend {
52
+ private readonly deps;
53
+ readonly name = "keychain";
54
+ /**
55
+ * @param deps - Injection seam for the `security` CLI and the
56
+ * platform/environment that decide the read path. Omitted in production;
57
+ * supplied by tests so the exact argv is assertable.
58
+ */
59
+ constructor(deps?: Partial<KeychainDeps>);
60
+ static detect(): boolean;
61
+ store(key: string, value: string): boolean;
62
+ retrieve(key: string): string | null;
63
+ delete(key: string): boolean;
64
+ listKeys(prefix?: string): Promise<string[]>;
65
+ /**
66
+ * The credential state probe: runs the SAME read {@link retrieve} would run
67
+ * in this context — same path, same explicit target — then discards the
68
+ * value and returns only the value-free {@link SecretState}.
69
+ *
70
+ * A metadata-only lookup would be cheaper and would leave the secret on
71
+ * disk, but it can only report presence: an item whose ACL does not
72
+ * preauthorize the reader, or that sits in a locked keychain, is present and
73
+ * unreadable, and calling that `found` hands a caller trusted state that is
74
+ * wrong in exactly the direction #188 exists to fix. `found` here means the
75
+ * reader will get bytes.
76
+ */
77
+ probe(key: string): SecretState;
78
+ }
24
79
  /**
25
80
  * Returns the type identifier for the active vault backend.
26
81
  *
27
82
  * Useful for diagnostics and health checks to know which backend was selected
28
- * at startup (e.g. "keychain", "libsecret", "gpg", "env").
83
+ * on first use (e.g. "keychain", "libsecret", "gpg", "env").
29
84
  */
30
85
  export declare function getActiveVaultType(): VaultType;
86
+ /** Legacy module-level shim over the default client. */
87
+ export declare function isSessionValid(): boolean;
31
88
  /**
32
89
  * Store a credential in the active vault backend.
33
90
  *
34
- * @param key - Logical key name (e.g. 'auth-token', 'refresh-token')
91
+ * @param key - Logical key name (e.g. 'auth-token', 'refresh-token').
92
+ * Must match the key grammar — see "Key validation" above.
35
93
  * @param value - The credential value to store
36
94
  * @returns true on success, false if the backend write fails
95
+ * @throws {InvalidCredentialKeyError} if `key` does not match the grammar.
96
+ * Nothing is written and no backend is contacted.
37
97
  */
38
98
  export declare function storeCredential(key: string, value: string): Promise<boolean>;
39
99
  /**
@@ -41,6 +101,10 @@ export declare function storeCredential(key: string, value: string): Promise<boo
41
101
  *
42
102
  * @param key - Logical key name (e.g. 'auth-token')
43
103
  * @returns The stored value, or null if not found / backend unavailable
104
+ * @throws {InvalidCredentialKeyError} if `key` does not match the grammar.
105
+ * Deliberately not `null`: "malformed" and "not stored" are different
106
+ * answers, and returning the not-found shape for the first is the silent
107
+ * degradation this path used to have.
44
108
  */
45
109
  export declare function getCredential(key: string): Promise<string | null>;
46
110
  /**
@@ -48,6 +112,9 @@ export declare function getCredential(key: string): Promise<string | null>;
48
112
  *
49
113
  * @param key - Logical key name (e.g. 'auth-token')
50
114
  * @returns true on success (including "already deleted"), false on unexpected error
115
+ * @throws {InvalidCredentialKeyError} if `key` does not match the grammar.
116
+ * Not reported as an idempotent success: such a key was never storable, so
117
+ * "already deleted" would be a claim the vault cannot make.
51
118
  */
52
119
  export declare function deleteCredential(key: string): Promise<boolean>;
53
120
  /**
@@ -64,7 +131,15 @@ export declare function deleteCredential(key: string): Promise<boolean>;
64
131
  * Note: credential keys must match `isValidKey` — lowercase alphanumeric
65
132
  * plus hyphen and dot, first and last character alphanumeric, <=64 chars.
66
133
  * Both `-` and `.` work as namespace separators; pick whichever convention
67
- * reads best.
134
+ * reads best. **This is enforced, not merely documented** (#168): every
135
+ * function on this path rejects a non-conforming key before dispatching.
136
+ *
137
+ * A `prefix` is checked against `isValidKeyPrefix` rather than `isValidKey`,
138
+ * so it may end on a separator — `"soma.anthropic."` is the intended way to
139
+ * scope an enumeration to a namespace, and is not itself a valid key.
140
+ *
141
+ * @throws {InvalidCredentialKeyError} if `prefix` could not be the leading
142
+ * substring of any valid key.
68
143
  *
69
144
  * @example
70
145
  * // Enumerate all soma-owned Anthropic credentials
@@ -75,4 +150,40 @@ export declare function deleteCredential(key: string): Promise<boolean>;
75
150
  * }
76
151
  */
77
152
  export declare function listCredentials(prefix?: string): Promise<string[]>;
153
+ /** A value-free answer about one credential in one backend. */
154
+ export interface BackendStateProbe {
155
+ /** Always `"value"` — distinguishes this from the key-layer probe. */
156
+ layer: "value";
157
+ /** Which backend answered. */
158
+ backend: VaultType;
159
+ state: SecretState;
160
+ }
161
+ /**
162
+ * Probe one credential's state in a specific backend.
163
+ *
164
+ * Backends that implement `probe()` answer directly. For the rest this falls
165
+ * back to a `retrieve()`-based mapping — the value is discarded immediately
166
+ * and never returned — which yields `found`/`absent` honestly and `failed`
167
+ * when the backend threw. It never invents `locked`, because a backend that
168
+ * did not report one cannot be assumed to have one, and it never converts a
169
+ * throw into `absent`.
170
+ *
171
+ * Both paths answer readability, not mere presence: the fallback runs the real
172
+ * `retrieve()`, and an implementing backend is required to exercise the same
173
+ * access path it would for a read (see `VaultBackend.probe`). `found`
174
+ * therefore means the reader will get bytes.
175
+ *
176
+ * @throws {InvalidCredentialKeyError} if `key` does not match the grammar —
177
+ * the same rule every other vault entry point enforces (#168). Policy hooks
178
+ * are deliberately NOT run: a probe reads no value, so there is nothing for
179
+ * an access-control hook to allow or deny.
180
+ */
181
+ export declare function probeVaultBackendState(backend: VaultBackend, type: VaultType, key: string): Promise<BackendStateProbe>;
182
+ /**
183
+ * Probe one credential's state in the backend this process actually uses.
184
+ *
185
+ * The value-layer half of the two-layer probe; see `probeKeyProviderState()`
186
+ * for the vault master key.
187
+ */
188
+ export declare function probeBackendState(key: string): Promise<BackendStateProbe>;
78
189
  //# sourceMappingURL=vault.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"vault.d.ts","sourceRoot":"","sources":["../../src/vault/vault.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAYH,OAAO,KAAK,EAAgB,SAAS,EAAE,MAAM,YAAY,CAAC;AAiE1D;;;GAGG;AACH,wBAAgB,cAAc,IAAI,OAAO,CAGxC;AAiBD;;;;;GAKG;AACH,wBAAgB,kBAAkB,IAAI,SAAS,CAE9C;AAED;;;;;;GAMG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAoBlB;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiCvE;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAmBpE;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,eAAe,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAkCxE"}
1
+ {"version":3,"file":"vault.d.ts","sourceRoot":"","sources":["../../src/vault/vault.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AASH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AACjE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAMtD,OAAO,KAAK,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAe1D;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,kBAAkB,CAAC;AAMrD;;;;;;;;;;GAUG;AACH,qBAAa,aAAc,YAAW,YAAY;IAQpC,OAAO,CAAC,QAAQ,CAAC,IAAI;IAPjC,QAAQ,CAAC,IAAI,cAAc;IAE3B;;;;OAIG;gBAC0B,IAAI,GAAE,OAAO,CAAC,YAAY,CAAM;IAE7D,MAAM,CAAC,MAAM,IAAI,OAAO;IAIxB,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO;IAI1C,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI;IAIpC,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO;IAItB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAIlD;;;;;;;;;;;OAWG;IACH,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,WAAW;CAGhC;AA6GD;;;;;GAKG;AACH,wBAAgB,kBAAkB,IAAI,SAAS,CAE9C;AAED,wDAAwD;AACxD,wBAAgB,cAAc,IAAI,OAAO,CAExC;AAED;;;;;;;;;GASG;AACH,wBAAsB,eAAe,CACnC,GAAG,EAAE,MAAM,EACX,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC,OAAO,CAAC,CAElB;AAED;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAEvE;AAED;;;;;;;;GAQG;AACH,wBAAsB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEpE;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAsB,eAAe,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAExE;AAuBD,+DAA+D;AAC/D,MAAM,WAAW,iBAAiB;IAChC,sEAAsE;IACtE,KAAK,EAAE,OAAO,CAAC;IACf,8BAA8B;IAC9B,OAAO,EAAE,SAAS,CAAC;IACnB,KAAK,EAAE,WAAW,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,sBAAsB,CAC1C,OAAO,EAAE,YAAY,EACrB,IAAI,EAAE,SAAS,EACf,GAAG,EAAE,MAAM,GACV,OAAO,CAAC,iBAAiB,CAAC,CAiC5B;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAG/E"}
@@ -8,56 +8,156 @@
8
8
  * 4. GpgVault — GPG-encrypted files (Linux / WSL)
9
9
  * 5. EnvVault — Environment variable fallback (always available)
10
10
  *
11
- * The active backend is selected once at module load by calling each backend's
11
+ * The default backend is selected lazily on first use by calling each backend's
12
12
  * static `detect()` method in order and choosing the first one that returns true.
13
+ * A successful selection is then reused for the lifetime of this module.
14
+ *
15
+ * **Explicit selection (ADR-004) sits in front of that cascade.** `secrets.backend`
16
+ * (or `CENTIENT_SECRETS_BACKEND`) names a backend directly and fails closed if it
17
+ * is unusable — it is never silently substituted. `OnePasswordVault` is reachable
18
+ * *only* this way: having `op` installed is not consent to route credentials into
19
+ * someone's 1Password vault.
13
20
  *
14
21
  * Session TTL: 4 hours from the last successful read/write.
15
22
  *
16
- * Error handling: all functions return null/false on failure never throw.
23
+ * Error handling: a *storage* failure is reported as `null`/`false`the
24
+ * backend contract is non-throwing. Three things do throw, because they are
25
+ * not storage outcomes: a policy `before` hook rejecting the operation
26
+ * (ADR-002 §1.0.0), a backend surfacing an unexpected transport failure from
27
+ * `retrieve`/`listKeys`, and a malformed credential key
28
+ * (`InvalidCredentialKeyError`, see the key-validation section below).
17
29
  */
18
30
  import { storeStringInKeychain, getStringFromKeychain, deleteFromKeychain, listAccountsInKeychain, } from "../crypto/vault-common.js";
31
+ import { probeKeychainCredentialState } from "../crypto/darwin-keychain.js";
19
32
  import { WindowsVault } from "./vault-windows.js";
20
33
  import { LibsecretVault } from "./vault-libsecret.js";
21
34
  import { GpgVault } from "./vault-gpg.js";
22
35
  import { EnvVault } from "./vault-env.js";
23
- import { runBeforeHooks, runAfterHooks } from "./policy.js";
36
+ import { OnePasswordVault } from "./vault-onepassword.js";
37
+ import { loadConfig } from "../key-providers/resolve.js";
38
+ import { defaultPolicyRunner } from "./policy.js";
39
+ import { assertValidKey } from "./vault-utils.js";
40
+ import { createSecretsClientWithPolicyRunner } from "../client.js";
24
41
  // =============================================================================
25
42
  // Constants
26
43
  // =============================================================================
27
- const AUTH_KEYCHAIN_SERVICE = "centient-auth";
28
- const SESSION_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
44
+ /**
45
+ * The keychain service every credential VALUE lives under. Exported because
46
+ * the migration helper (`migrateLoginCredentialToSystem`) and operator
47
+ * tooling have to name the same service — a second literal somewhere else is
48
+ * how a migration silently moves nothing.
49
+ */
50
+ export const AUTH_KEYCHAIN_SERVICE = "centient-auth";
29
51
  // =============================================================================
30
52
  // KeychainVault wrapper
31
53
  // =============================================================================
32
54
  /**
33
- * Wraps the existing macOS Keychain helper functions as a VaultBackend.
55
+ * Wraps the macOS Keychain helper functions as a VaultBackend.
56
+ *
57
+ * Every method routes through `crypto/darwin-keychain.ts` (#188), so credential
58
+ * values inherit explicit System-keychain reads in a sessionless context,
59
+ * `-U` replace-in-place writes and `-T /usr/bin/security` reader
60
+ * preauthorization — the same posture the KeyProvider layer gets for the vault
61
+ * master key.
62
+ *
34
63
  * Only available on macOS (darwin).
35
64
  */
36
- class KeychainVault {
65
+ export class KeychainVault {
66
+ deps;
67
+ name = "keychain";
68
+ /**
69
+ * @param deps - Injection seam for the `security` CLI and the
70
+ * platform/environment that decide the read path. Omitted in production;
71
+ * supplied by tests so the exact argv is assertable.
72
+ */
73
+ constructor(deps = {}) {
74
+ this.deps = deps;
75
+ }
37
76
  static detect() {
38
77
  return process.platform === "darwin";
39
78
  }
40
79
  store(key, value) {
41
- return storeStringInKeychain(AUTH_KEYCHAIN_SERVICE, key, value);
80
+ return storeStringInKeychain(AUTH_KEYCHAIN_SERVICE, key, value, this.deps);
42
81
  }
43
82
  retrieve(key) {
44
- return getStringFromKeychain(AUTH_KEYCHAIN_SERVICE, key);
83
+ return getStringFromKeychain(AUTH_KEYCHAIN_SERVICE, key, this.deps);
45
84
  }
46
85
  delete(key) {
47
- return deleteFromKeychain(AUTH_KEYCHAIN_SERVICE, key);
86
+ return deleteFromKeychain(AUTH_KEYCHAIN_SERVICE, key, this.deps);
48
87
  }
49
88
  async listKeys(prefix) {
50
- return listAccountsInKeychain(AUTH_KEYCHAIN_SERVICE, prefix);
89
+ return listAccountsInKeychain(AUTH_KEYCHAIN_SERVICE, prefix, this.deps);
90
+ }
91
+ /**
92
+ * The credential state probe: runs the SAME read {@link retrieve} would run
93
+ * in this context — same path, same explicit target — then discards the
94
+ * value and returns only the value-free {@link SecretState}.
95
+ *
96
+ * A metadata-only lookup would be cheaper and would leave the secret on
97
+ * disk, but it can only report presence: an item whose ACL does not
98
+ * preauthorize the reader, or that sits in a locked keychain, is present and
99
+ * unreadable, and calling that `found` hands a caller trusted state that is
100
+ * wrong in exactly the direction #188 exists to fix. `found` here means the
101
+ * reader will get bytes.
102
+ */
103
+ probe(key) {
104
+ return probeKeychainCredentialState(AUTH_KEYCHAIN_SERVICE, key, this.deps);
51
105
  }
52
106
  }
53
107
  // =============================================================================
54
108
  // Cascade initialization
55
109
  // =============================================================================
56
110
  /**
57
- * Selects the first available vault backend in priority order:
111
+ * Read the explicitly-selected backend, env taking precedence over config —
112
+ * the same env > config order the key layer uses (ADR-001).
113
+ *
114
+ * Returns null when nothing is explicitly selected, which is the signal to run
115
+ * the auto-cascade unchanged.
116
+ */
117
+ function resolveExplicitBackend() {
118
+ const envBackend = process.env.CENTIENT_SECRETS_BACKEND?.trim();
119
+ const config = loadConfig().secrets ?? {};
120
+ const selected = envBackend || config.backend;
121
+ if (!selected)
122
+ return null;
123
+ if (selected !== "1password") {
124
+ throw new Error(`Unknown secrets backend "${selected}". Supported explicit backends: 1password.`);
125
+ }
126
+ const envVault = process.env.CENTIENT_OP_VAULT?.trim();
127
+ const onePasswordBackend = {
128
+ ...config.onePasswordBackend,
129
+ ...(envVault ? { vault: envVault } : {}),
130
+ };
131
+ return { type: selected, onePasswordBackend };
132
+ }
133
+ /**
134
+ * Selects the credential-storage backend.
135
+ *
136
+ * **Explicit selection wins and fails closed** (ADR-004 §1): if config or env
137
+ * names a backend that then turns out to be unusable — no vault configured, `op`
138
+ * missing or unauthenticated — this throws rather than quietly continuing down
139
+ * the auto-cascade. Falling through would silently store credentials somewhere
140
+ * other than where the operator said, which is the whole class of surprise the
141
+ * opt-in model exists to prevent (P2).
142
+ *
143
+ * With no explicit selection, the historical auto-cascade runs unchanged:
58
144
  * Keychain -> Windows -> Libsecret -> GPG -> Env
145
+ * 1Password is **never** auto-selected — `op` being installed is not consent to
146
+ * route credentials into it.
59
147
  */
60
148
  function initVaultBackend() {
149
+ const explicit = resolveExplicitBackend();
150
+ if (explicit !== null) {
151
+ // Constructor throws on a missing vault name — the fail-closed half of §1.
152
+ const backend = new OnePasswordVault(explicit.onePasswordBackend);
153
+ if (!OnePasswordVault.detect(true)) {
154
+ throw new Error('secrets backend "1password" was explicitly selected, but the 1Password CLI ' +
155
+ "(`op`) is unavailable or not authenticated. Install `op` and sign in, or set " +
156
+ "OP_SERVICE_ACCOUNT_TOKEN. Refusing to fall back to another backend — an " +
157
+ "explicit backend choice is never silently substituted.");
158
+ }
159
+ return { backend, type: "1password" };
160
+ }
61
161
  if (KeychainVault.detect())
62
162
  return { backend: new KeychainVault(), type: "keychain" };
63
163
  if (WindowsVault.detect())
@@ -68,31 +168,29 @@ function initVaultBackend() {
68
168
  return { backend: new GpgVault(), type: "gpg" };
69
169
  return { backend: new EnvVault(), type: "env" };
70
170
  }
71
- const { backend: activeBackend, type: activeVaultType } = initVaultBackend();
72
- // =============================================================================
73
- // Session State
74
- // =============================================================================
75
- /** Last successful vault access timestamp (epoch ms). */
76
- let lastAccessAt = null;
77
- /**
78
- * Returns true if the in-process session is still within the TTL window.
79
- * This does NOT validate the token itself — use validateToken() for that.
80
- */
81
- export function isSessionValid() {
82
- if (lastAccessAt === null)
83
- return false;
84
- return Date.now() - lastAccessAt < SESSION_TTL_MS;
85
- }
86
- /** Update session timestamp on successful access. */
87
- function touchSession() {
88
- lastAccessAt = Date.now();
171
+ let defaultVault;
172
+ function getDefaultVault() {
173
+ if (defaultVault !== undefined)
174
+ return defaultVault;
175
+ const { backend, type } = initVaultBackend();
176
+ const initialized = {
177
+ backend,
178
+ type,
179
+ client: createSecretsClientWithPolicyRunner({
180
+ provider: backend,
181
+ providerName: type,
182
+ policyRunner: defaultPolicyRunner,
183
+ }),
184
+ };
185
+ defaultVault = initialized;
186
+ return initialized;
89
187
  }
90
188
  // =============================================================================
91
189
  // Public API
92
190
  //
93
191
  // Policy-rejected operations are audited: when a `before` hook throws,
94
- // the rejection propagates to the caller AND the `after` hooks of the
95
- // already-entered policies fire with a `*_rejected` event (see
192
+ // the rejection propagates to the caller AND the rejecting policy plus the
193
+ // already-entered policies fire `after` with a `*_rejected` event (see
96
194
  // `runBeforeHooks` in policy.ts), so a denied operation is never
97
195
  // invisible to the audit trail. ADR-002 §1.0.0.
98
196
  // =============================================================================
@@ -100,107 +198,52 @@ function touchSession() {
100
198
  * Returns the type identifier for the active vault backend.
101
199
  *
102
200
  * Useful for diagnostics and health checks to know which backend was selected
103
- * at startup (e.g. "keychain", "libsecret", "gpg", "env").
201
+ * on first use (e.g. "keychain", "libsecret", "gpg", "env").
104
202
  */
105
203
  export function getActiveVaultType() {
106
- return activeVaultType;
204
+ return getDefaultVault().type;
205
+ }
206
+ /** Legacy module-level shim over the default client. */
207
+ export function isSessionValid() {
208
+ return getDefaultVault().client.isSessionValid();
107
209
  }
108
210
  /**
109
211
  * Store a credential in the active vault backend.
110
212
  *
111
- * @param key - Logical key name (e.g. 'auth-token', 'refresh-token')
213
+ * @param key - Logical key name (e.g. 'auth-token', 'refresh-token').
214
+ * Must match the key grammar — see "Key validation" above.
112
215
  * @param value - The credential value to store
113
216
  * @returns true on success, false if the backend write fails
217
+ * @throws {InvalidCredentialKeyError} if `key` does not match the grammar.
218
+ * Nothing is written and no backend is contacted.
114
219
  */
115
220
  export async function storeCredential(key, value) {
116
- const start = performance.now();
117
- await runBeforeHooks({ type: "write", key }, (error) => ({
118
- type: "credential_write_rejected",
119
- timestamp: new Date().toISOString(),
120
- backend: activeVaultType,
121
- key,
122
- error,
123
- durationMs: performance.now() - start,
124
- }));
125
- const success = activeBackend.store(key, value);
126
- if (success)
127
- touchSession();
128
- runAfterHooks({
129
- type: success ? "credential_written" : "credential_write_failed",
130
- timestamp: new Date().toISOString(),
131
- backend: activeVaultType,
132
- key,
133
- durationMs: performance.now() - start,
134
- });
135
- return success;
221
+ return getDefaultVault().client.storeCredential(key, value);
136
222
  }
137
223
  /**
138
224
  * Retrieve a credential from the active vault backend.
139
225
  *
140
226
  * @param key - Logical key name (e.g. 'auth-token')
141
227
  * @returns The stored value, or null if not found / backend unavailable
228
+ * @throws {InvalidCredentialKeyError} if `key` does not match the grammar.
229
+ * Deliberately not `null`: "malformed" and "not stored" are different
230
+ * answers, and returning the not-found shape for the first is the silent
231
+ * degradation this path used to have.
142
232
  */
143
233
  export async function getCredential(key) {
144
- const start = performance.now();
145
- await runBeforeHooks({ type: "read", key }, (error) => ({
146
- type: "credential_read_rejected",
147
- timestamp: new Date().toISOString(),
148
- backend: activeVaultType,
149
- key,
150
- error,
151
- durationMs: performance.now() - start,
152
- }));
153
- let value;
154
- try {
155
- value = activeBackend.retrieve(key);
156
- }
157
- catch (err) {
158
- runAfterHooks({
159
- type: "credential_read_failed",
160
- timestamp: new Date().toISOString(),
161
- backend: activeVaultType,
162
- key,
163
- error: err instanceof Error ? err.message : String(err),
164
- durationMs: performance.now() - start,
165
- });
166
- throw err;
167
- }
168
- if (value !== null)
169
- touchSession();
170
- runAfterHooks({
171
- type: value !== null ? "credential_read" : "credential_read_missing",
172
- timestamp: new Date().toISOString(),
173
- backend: activeVaultType,
174
- key,
175
- durationMs: performance.now() - start,
176
- });
177
- return value;
234
+ return getDefaultVault().client.getCredential(key);
178
235
  }
179
236
  /**
180
237
  * Delete a credential from the active vault backend.
181
238
  *
182
239
  * @param key - Logical key name (e.g. 'auth-token')
183
240
  * @returns true on success (including "already deleted"), false on unexpected error
241
+ * @throws {InvalidCredentialKeyError} if `key` does not match the grammar.
242
+ * Not reported as an idempotent success: such a key was never storable, so
243
+ * "already deleted" would be a claim the vault cannot make.
184
244
  */
185
245
  export async function deleteCredential(key) {
186
- const start = performance.now();
187
- await runBeforeHooks({ type: "delete", key }, (error) => ({
188
- type: "credential_delete_rejected",
189
- timestamp: new Date().toISOString(),
190
- backend: activeVaultType,
191
- key,
192
- error,
193
- durationMs: performance.now() - start,
194
- }));
195
- const success = activeBackend.delete(key);
196
- runAfterHooks({
197
- type: success ? "credential_deleted" : "credential_delete_failed",
198
- timestamp: new Date().toISOString(),
199
- backend: activeVaultType,
200
- key,
201
- durationMs: performance.now() - start,
202
- });
203
- return success;
246
+ return getDefaultVault().client.deleteCredential(key);
204
247
  }
205
248
  /**
206
249
  * Enumerate credential keys in the active vault backend, optionally
@@ -216,7 +259,15 @@ export async function deleteCredential(key) {
216
259
  * Note: credential keys must match `isValidKey` — lowercase alphanumeric
217
260
  * plus hyphen and dot, first and last character alphanumeric, <=64 chars.
218
261
  * Both `-` and `.` work as namespace separators; pick whichever convention
219
- * reads best.
262
+ * reads best. **This is enforced, not merely documented** (#168): every
263
+ * function on this path rejects a non-conforming key before dispatching.
264
+ *
265
+ * A `prefix` is checked against `isValidKeyPrefix` rather than `isValidKey`,
266
+ * so it may end on a separator — `"soma.anthropic."` is the intended way to
267
+ * scope an enumeration to a namespace, and is not itself a valid key.
268
+ *
269
+ * @throws {InvalidCredentialKeyError} if `prefix` could not be the leading
270
+ * substring of any valid key.
220
271
  *
221
272
  * @example
222
273
  * // Enumerate all soma-owned Anthropic credentials
@@ -227,40 +278,72 @@ export async function deleteCredential(key) {
227
278
  * }
228
279
  */
229
280
  export async function listCredentials(prefix) {
230
- const start = performance.now();
231
- await runBeforeHooks({ type: "enumerate", prefix }, (error) => ({
232
- type: "credential_enumerate_rejected",
233
- timestamp: new Date().toISOString(),
234
- backend: activeVaultType,
235
- prefix,
236
- error,
237
- durationMs: performance.now() - start,
238
- }));
239
- let keys;
281
+ return getDefaultVault().client.listCredentials(prefix);
282
+ }
283
+ /**
284
+ * Probe one credential's state in a specific backend.
285
+ *
286
+ * Backends that implement `probe()` answer directly. For the rest this falls
287
+ * back to a `retrieve()`-based mapping — the value is discarded immediately
288
+ * and never returned — which yields `found`/`absent` honestly and `failed`
289
+ * when the backend threw. It never invents `locked`, because a backend that
290
+ * did not report one cannot be assumed to have one, and it never converts a
291
+ * throw into `absent`.
292
+ *
293
+ * Both paths answer readability, not mere presence: the fallback runs the real
294
+ * `retrieve()`, and an implementing backend is required to exercise the same
295
+ * access path it would for a read (see `VaultBackend.probe`). `found`
296
+ * therefore means the reader will get bytes.
297
+ *
298
+ * @throws {InvalidCredentialKeyError} if `key` does not match the grammar —
299
+ * the same rule every other vault entry point enforces (#168). Policy hooks
300
+ * are deliberately NOT run: a probe reads no value, so there is nothing for
301
+ * an access-control hook to allow or deny.
302
+ */
303
+ export async function probeVaultBackendState(backend, type, key) {
304
+ assertValidKey(key, "read");
305
+ if (typeof backend.probe === "function") {
306
+ try {
307
+ return { layer: "value", backend: type, state: await backend.probe(key) };
308
+ }
309
+ catch (err) {
310
+ return {
311
+ layer: "value",
312
+ backend: type,
313
+ state: {
314
+ kind: "failed",
315
+ detail: err instanceof Error ? err.message : String(err),
316
+ },
317
+ };
318
+ }
319
+ }
240
320
  try {
241
- keys = await activeBackend.listKeys(prefix);
321
+ const value = await backend.retrieve(key);
322
+ return {
323
+ layer: "value",
324
+ backend: type,
325
+ state: value !== null ? { kind: "found" } : { kind: "absent" },
326
+ };
242
327
  }
243
328
  catch (err) {
244
- runAfterHooks({
245
- type: "credential_enumerate_failed",
246
- timestamp: new Date().toISOString(),
247
- backend: activeVaultType,
248
- prefix,
249
- error: err instanceof Error ? err.message : String(err),
250
- durationMs: performance.now() - start,
251
- });
252
- throw err;
329
+ return {
330
+ layer: "value",
331
+ backend: type,
332
+ state: {
333
+ kind: "failed",
334
+ detail: err instanceof Error ? err.message : String(err),
335
+ },
336
+ };
253
337
  }
254
- if (keys.length > 0)
255
- touchSession();
256
- runAfterHooks({
257
- type: "credential_enumerated",
258
- timestamp: new Date().toISOString(),
259
- backend: activeVaultType,
260
- prefix,
261
- keyCount: keys.length,
262
- durationMs: performance.now() - start,
263
- });
264
- return keys;
338
+ }
339
+ /**
340
+ * Probe one credential's state in the backend this process actually uses.
341
+ *
342
+ * The value-layer half of the two-layer probe; see `probeKeyProviderState()`
343
+ * for the vault master key.
344
+ */
345
+ export async function probeBackendState(key) {
346
+ const { backend, type } = getDefaultVault();
347
+ return probeVaultBackendState(backend, type, key);
265
348
  }
266
349
  //# sourceMappingURL=vault.js.map