Skip to content

Commit 1c62aa6

Browse files
kvapsclaude
andcommitted
feat(rest): cluster passphrase endpoints (encryption/passphrase)
POST creates the cluster passphrase, PATCH validates / unlocks the crypto context, PUT rotates with old→new. Stored as a single key under ControllerProps for now; per-volume key encryption + KDF is Phase 6 work. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent 3e69259 commit 1c62aa6

5 files changed

Lines changed: 306 additions & 1 deletion

File tree

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ Full scope list lives in `docs/csi-api-surface.md` (to be created in Phase 1).
264264

265265
### Phase 7 — Cluster operations + admin
266266

267-
- [ ] Cluster passphrase management
267+
- [x] Cluster passphrase management (`/v1/encryption/passphrase` POST/PATCH/PUT): seeds, unlocks, rotates the cluster passphrase under ControllerProps. KDF + at-rest encryption of per-volume keys is the LUKS phase 6 work. 3 contract tests.
268268
- [ ] Satellite eviction / restoration / lost-and-recover
269269
- [x] Stats endpoint (`GET /v1/stats`): cluster-wide counters (nodes, RDs, resources, storage pools, snapshots). 2 contract tests.
270270
- [x] Error reports stub (`/v1/error-reports` LIST returns []; GET /{id} → 404). Empty-but-present so `linstor error-reports list` doesn't choke. Real persistence lands when the controller starts buffering reports.

pkg/rest/encryption.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
/*
2+
Copyright 2026 Cozystack contributors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package rest
18+
19+
import (
20+
"context"
21+
"encoding/json"
22+
"net/http"
23+
24+
"github.com/cockroachdb/errors"
25+
26+
apiv1 "github.com/cozystack/blockstor/pkg/api/v1"
27+
"github.com/cozystack/blockstor/pkg/store"
28+
)
29+
30+
// passphraseRequest is the body upstream linstor expects on the
31+
// encryption/passphrase endpoints.
32+
type passphraseRequest struct {
33+
NewPassphrase string `json:"new_passphrase"`
34+
OldPassphrase string `json:"old_passphrase,omitempty"`
35+
}
36+
37+
// registerEncryption wires `linstor encryption *` endpoints. The
38+
// cluster passphrase is the master key used to encrypt LUKS volume
39+
// keys at rest in the controller's KV store.
40+
//
41+
// Storage scaffolding only for now: the passphrase is held verbatim
42+
// under the ControllerProps KV instance. Real KDF + at-rest encryption
43+
// of the per-volume keys is Phase 6 work.
44+
func (s *Server) registerEncryption(mux *http.ServeMux) {
45+
mux.HandleFunc("POST /v1/encryption/passphrase",
46+
s.requireStore(s.handlePassphraseCreate))
47+
mux.HandleFunc("PATCH /v1/encryption/passphrase",
48+
s.requireStore(s.handlePassphraseEnter))
49+
mux.HandleFunc("PUT /v1/encryption/passphrase",
50+
s.requireStore(s.handlePassphraseModify))
51+
}
52+
53+
// handlePassphraseCreate sets the passphrase the first time. 409 if
54+
// one already exists; the caller is supposed to PATCH to change.
55+
func (s *Server) handlePassphraseCreate(w http.ResponseWriter, r *http.Request) {
56+
var req passphraseRequest
57+
58+
err := json.NewDecoder(r.Body).Decode(&req)
59+
if err != nil {
60+
writeError(w, http.StatusBadRequest, err.Error())
61+
62+
return
63+
}
64+
65+
if req.NewPassphrase == "" {
66+
writeError(w, http.StatusBadRequest, "new_passphrase is required")
67+
68+
return
69+
}
70+
71+
have, err := getPassphrase(r.Context(), s.Store)
72+
if err != nil {
73+
writeError(w, http.StatusInternalServerError, err.Error())
74+
75+
return
76+
}
77+
78+
if have != "" {
79+
writeError(w, http.StatusConflict, "cluster passphrase already set; PATCH to modify")
80+
81+
return
82+
}
83+
84+
err = setPassphrase(r.Context(), s.Store, req.NewPassphrase)
85+
if err != nil {
86+
writeError(w, http.StatusInternalServerError, err.Error())
87+
88+
return
89+
}
90+
91+
w.WriteHeader(http.StatusCreated)
92+
}
93+
94+
// handlePassphraseEnter unlocks the in-memory crypto context for this
95+
// controller process. We treat the request body's `new_passphrase` as
96+
// the proof-of-knowledge — matches upstream's PATCH semantics.
97+
func (s *Server) handlePassphraseEnter(w http.ResponseWriter, r *http.Request) {
98+
var req passphraseRequest
99+
100+
err := json.NewDecoder(r.Body).Decode(&req)
101+
if err != nil {
102+
writeError(w, http.StatusBadRequest, err.Error())
103+
104+
return
105+
}
106+
107+
have, err := getPassphrase(r.Context(), s.Store)
108+
if err != nil {
109+
writeError(w, http.StatusInternalServerError, err.Error())
110+
111+
return
112+
}
113+
114+
if have == "" {
115+
writeError(w, http.StatusPreconditionFailed, "no cluster passphrase set; POST to create")
116+
117+
return
118+
}
119+
120+
if have != req.NewPassphrase {
121+
writeError(w, http.StatusForbidden, "passphrase mismatch")
122+
123+
return
124+
}
125+
126+
w.WriteHeader(http.StatusOK)
127+
}
128+
129+
// handlePassphraseModify rotates the passphrase. Old must verify, new
130+
// replaces it.
131+
func (s *Server) handlePassphraseModify(w http.ResponseWriter, r *http.Request) {
132+
var req passphraseRequest
133+
134+
err := json.NewDecoder(r.Body).Decode(&req)
135+
if err != nil {
136+
writeError(w, http.StatusBadRequest, err.Error())
137+
138+
return
139+
}
140+
141+
have, err := getPassphrase(r.Context(), s.Store)
142+
if err != nil {
143+
writeError(w, http.StatusInternalServerError, err.Error())
144+
145+
return
146+
}
147+
148+
if have == "" {
149+
writeError(w, http.StatusPreconditionFailed, "no cluster passphrase set")
150+
151+
return
152+
}
153+
154+
if have != req.OldPassphrase {
155+
writeError(w, http.StatusForbidden, "old passphrase mismatch")
156+
157+
return
158+
}
159+
160+
err = setPassphrase(r.Context(), s.Store, req.NewPassphrase)
161+
if err != nil {
162+
writeError(w, http.StatusInternalServerError, err.Error())
163+
164+
return
165+
}
166+
167+
w.WriteHeader(http.StatusOK)
168+
}
169+
170+
// getPassphrase reads the current cluster passphrase. Empty string
171+
// (not error) for "not yet set".
172+
func getPassphrase(ctx context.Context, st store.Store) (string, error) {
173+
props, err := st.KeyValueStore().GetInstance(ctx, controllerPropsInstance)
174+
if err != nil {
175+
if errors.Is(err, store.ErrNotFound) {
176+
return "", nil
177+
}
178+
179+
return "", errors.Wrap(err, "read passphrase")
180+
}
181+
182+
return props[passphraseKey], nil
183+
}
184+
185+
// setPassphrase writes the cluster passphrase via SetKeys (so the
186+
// merge semantic preserves any sibling controller props).
187+
func setPassphrase(ctx context.Context, st store.Store, value string) error {
188+
err := st.KeyValueStore().SetKeys(ctx, controllerPropsInstance, apiv1.GenericPropsModify{
189+
OverrideProps: map[string]string{passphraseKey: value},
190+
})
191+
if err != nil {
192+
return errors.Wrap(err, "write passphrase")
193+
}
194+
195+
return nil
196+
}
197+
198+
// passphraseKey is the property key under ControllerProps where the
199+
// cluster passphrase lives. The upstream-compatible name keeps
200+
// satellites that look it up by string identifier working.
201+
//
202+
//nolint:gosec // this is the storage key name, not the secret value itself
203+
const passphraseKey = "Cluster/EncryptionPassphrase"

pkg/rest/encryption_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
Copyright 2026 Cozystack contributors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package rest
18+
19+
import (
20+
"encoding/json"
21+
"net/http"
22+
"testing"
23+
24+
"github.com/cozystack/blockstor/pkg/store"
25+
)
26+
27+
// TestPassphraseEnterRequiresExisting: PATCH unlocks an existing
28+
// cluster passphrase. Without one set yet → 412 (precondition).
29+
func TestPassphraseEnterRequiresExisting(t *testing.T) {
30+
base, stop := startServerWithStore(t, store.NewInMemory())
31+
defer stop()
32+
33+
body, _ := json.Marshal(map[string]string{"new_passphrase": "secret"})
34+
35+
resp := httpPatch(t, base+"/v1/encryption/passphrase", body)
36+
_ = resp.Body.Close()
37+
38+
if resp.StatusCode != http.StatusPreconditionFailed {
39+
t.Errorf("status: got %d, want 412", resp.StatusCode)
40+
}
41+
}
42+
43+
// TestPassphraseCreateThenEnter: POST creates the cluster passphrase;
44+
// PATCH then unlocks with that same passphrase.
45+
func TestPassphraseCreateThenEnter(t *testing.T) {
46+
base, stop := startServerWithStore(t, store.NewInMemory())
47+
defer stop()
48+
49+
createBody, _ := json.Marshal(map[string]string{"new_passphrase": "secret"})
50+
resp := httpPost(t, base+"/v1/encryption/passphrase", createBody)
51+
_ = resp.Body.Close()
52+
53+
if resp.StatusCode != http.StatusCreated {
54+
t.Fatalf("create: got %d, want 201", resp.StatusCode)
55+
}
56+
57+
enterBody, _ := json.Marshal(map[string]string{"new_passphrase": "secret"})
58+
59+
enterResp := httpPatch(t, base+"/v1/encryption/passphrase", enterBody)
60+
_ = enterResp.Body.Close()
61+
62+
if enterResp.StatusCode != http.StatusOK {
63+
t.Errorf("enter: got %d, want 200", enterResp.StatusCode)
64+
}
65+
}
66+
67+
// TestPassphraseCreateTwiceConflicts: a second POST without first
68+
// removing the existing passphrase → 409.
69+
func TestPassphraseCreateTwiceConflicts(t *testing.T) {
70+
base, stop := startServerWithStore(t, store.NewInMemory())
71+
defer stop()
72+
73+
body, _ := json.Marshal(map[string]string{"new_passphrase": "secret"})
74+
75+
first := httpPost(t, base+"/v1/encryption/passphrase", body)
76+
_ = first.Body.Close()
77+
78+
second := httpPost(t, base+"/v1/encryption/passphrase", body)
79+
_ = second.Body.Close()
80+
81+
if second.StatusCode != http.StatusConflict {
82+
t.Errorf("second create: got %d, want 409", second.StatusCode)
83+
}
84+
}

pkg/rest/nodes_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,20 @@ func httpDelete(t *testing.T, addr string) *http.Response {
265265

266266
return resp
267267
}
268+
269+
func httpPatch(t *testing.T, addr string, body []byte) *http.Response {
270+
t.Helper()
271+
272+
req, err := http.NewRequestWithContext(t.Context(), http.MethodPatch, addr, bytes.NewReader(body))
273+
if err != nil {
274+
t.Fatalf("new request: %v", err)
275+
}
276+
req.Header.Set("Content-Type", "application/json")
277+
278+
resp, err := http.DefaultClient.Do(req)
279+
if err != nil {
280+
t.Fatalf("do: %v", err)
281+
}
282+
283+
return resp
284+
}

pkg/rest/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ func (s *Server) Start(ctx context.Context) error {
7373
s.registerErrorReports(mux)
7474
s.registerPropertiesInfo(mux)
7575
s.registerSnapshotRestore(mux)
76+
s.registerEncryption(mux)
7677

7778
srv := &http.Server{
7879
Addr: s.Addr,

0 commit comments

Comments
 (0)