返回 DeepSeek-Reasonix
credential_rotation.go
根目录 / internal / config / credential_rotation.go
1 package config
2
3 import (
4 "crypto/rand"
5 "fmt"
6 "os"
7 "strings"
8 )
9
10 // credentialRotation is a key replaced in place. The previous value is kept in
11 // the credential store under Backup, never in the journal, so it has the same
12 // protection as every other stored key.
13 type credentialRotation struct {
14 Slot string `json:"slot"`
15 Backup string `json:"backup"`
16 Digest string `json:"digest"` // keyed digest of the value this edit wrote
17 }
18
19 // RotateModelCredentialLocked stores a new key for providers. When they are
20 // the only readers of the variable they share and the store holds its value,
21 // the variable is rewritten in place; otherwise the key gets a private slot as
22 // StageModelCredentialLocked does. Callers hold both edit locks.
23 func (c *Config) RotateModelCredentialLocked(providers []string, value string) (string, error) {
24 key, ok := c.rotatableCredentialKey(providers)
25 if !ok {
26 return c.StageModelCredentialLocked(value)
27 }
28 value = strings.TrimSpace(value)
29 if strings.ContainsAny(value, "\r\n") {
30 return "", fmt.Errorf("credential value contains a newline")
31 }
32 previous, _ := envFileValue(UserCredentialsPath(), key)
33 var id [16]byte
34 if _, err := rand.Read(id[:]); err != nil {
35 return "", err
36 }
37 digest, err := stagedCredentialDigest(value)
38 if err != nil {
39 return "", err
40 }
41 j := c.modelCredentialCommit
42 rotation := credentialRotation{Slot: key, Backup: fmt.Sprintf("REASONIX_ROTATION_%X_KEY", id), Digest: digest}
43 j.Rotations = append(j.Rotations, rotation)
44 j.Phase = "prepared"
45 if err := writeModelCredentialJournal(j); err != nil {
46 j.Rotations = j.Rotations[:len(j.Rotations)-1]
47 return "", err
48 }
49 // One file write: a crash leaves either both values or neither.
50 if err := storeCredentialsInFile(UserCredentialsPath(), map[string]string{rotation.Backup: previous, key: value}); err != nil {
51 return "", err
52 }
53 pinCredentialAssignments(map[string]string{key: value})
54 j.Phase = "credential_written"
55 if err := writeModelCredentialJournal(j); err != nil {
56 return "", err
57 }
58 return key, nil
59 }
60
61 // rotatableCredentialKey names the variable providers share when rewriting it
62 // changes what no other reader in the user config resolves, both in this edit
63 // and on disk. A project file cannot see the user config, so it never rotates.
64 func (c *Config) rotatableCredentialKey(providers []string) (string, bool) {
65 if c == nil || c.modelCredentialCommit == nil || len(providers) == 0 || !IsUserConfigPath(c.modelCredentialCommit.ConfigPath) {
66 return "", false
67 }
68 key, ok := sharedCredentialKey(c, providers)
69 if !ok || !isCredentialKey(key) || !onlyCredentialReaders(c, key, providers) || rotatedInThisEdit(c.modelCredentialCommit, key) {
70 return "", false
71 }
72 before, err := LoadForEditWithoutCredentialsReadOnlyStrict(c.modelCredentialCommit.ConfigPath)
73 if err != nil {
74 return "", false
75 }
76 if held, ok := sharedCredentialKey(before, providers); !ok || held != key || !onlyCredentialReaders(before, key, providers) {
77 return "", false
78 }
79 // The store overrides the shell at load, so a stored value is what every
80 // in-process reader resolves.
81 file, ok := readDotEnvFile(UserCredentialsPath())
82 if _, stored := file.Values[key]; !ok || !stored {
83 return "", false
84 }
85 for name := range file.Values {
86 if name != key && sameCredentialName(name, key) {
87 return "", false // Windows would load either spelling into one variable.
88 }
89 }
90 return key, true
91 }
92
93 func sharedCredentialKey(c *Config, providers []string) (string, bool) {
94 key := ""
95 for _, name := range providers {
96 entry, ok := c.Provider(strings.TrimSpace(name))
97 if !ok {
98 return "", false
99 }
100 env := strings.TrimSpace(entry.APIKeyEnv)
101 if env == "" || (key != "" && env != key) {
102 return "", false
103 }
104 key = env
105 }
106 return key, key != ""
107 }
108
109 func onlyCredentialReaders(c *Config, key string, providers []string) bool {
110 for _, p := range c.Providers {
111 if sameCredentialName(strings.TrimSpace(p.APIKeyEnv), key) && !containsTrimmed(providers, p.Name) {
112 return false
113 }
114 }
115 others := *c
116 others.Providers = nil
117 for _, name := range credentialEnvNamesFromConfig(&others) {
118 if sameCredentialName(name, key) {
119 return false
120 }
121 }
122 return true
123 }
124
125 // rotatedInThisEdit keeps one backup per variable: a second would hold this
126 // edit's own value, and restoring it would lose the original.
127 func rotatedInThisEdit(j *modelCredentialCommitJournal, key string) bool {
128 for _, r := range j.Rotations {
129 if sameCredentialName(r.Slot, key) {
130 return true
131 }
132 }
133 return false
134 }
135
136 func containsTrimmed(names []string, want string) bool {
137 for _, name := range names {
138 if strings.TrimSpace(name) == want {
139 return true
140 }
141 }
142 return false
143 }
144
145 // restoreRotations puts the previous value back only while the variable still
146 // holds what this edit wrote; a value another writer stored stays.
147 func restoreRotations(j *modelCredentialCommitJournal) error {
148 path := UserCredentialsPath()
149 for _, r := range j.Rotations {
150 previous, hasBackup := envFileValue(path, r.Backup)
151 if hasBackup && stagedValueUnchanged(r.Slot, r.Digest) {
152 if err := storeCredentialsInFile(path, map[string]string{r.Slot: previous}); err != nil {
153 return err
154 }
155 pinCredentialAssignments(map[string]string{r.Slot: previous})
156 }
157 if err := dropRotationBackup(path, r.Backup, hasBackup); err != nil {
158 return err
159 }
160 }
161 j.Rotations = nil
162 return nil
163 }
164
165 // dropRotationBackups settles a published rotation: the new value stays.
166 func dropRotationBackups(j *modelCredentialCommitJournal) error {
167 path := UserCredentialsPath()
168 for _, r := range j.Rotations {
169 _, hasBackup := envFileValue(path, r.Backup)
170 if err := dropRotationBackup(path, r.Backup, hasBackup); err != nil {
171 return err
172 }
173 }
174 j.Rotations = nil
175 return nil
176 }
177
178 // dropRotationBackup leaves no cleared marker: nothing else writes a random
179 // backup name, so a marker would only grow the file on every rotation.
180 func dropRotationBackup(path, backup string, stored bool) error {
181 if stored {
182 lines, err := readCredentialFileLinesForWrite(path)
183 if err != nil {
184 return err
185 }
186 kept := make([]string, 0, len(lines))
187 for _, line := range lines {
188 if k, ok := credentialLineKey(line); !ok || k != backup {
189 kept = append(kept, line)
190 }
191 }
192 if err := writeCredentialFileLines(path, kept); err != nil {
193 return err
194 }
195 }
196 _ = os.Unsetenv(backup)
197 return nil
198 }
199
199 lines GO