Skip to content

Commit 5df55b1

Browse files
authored
Move /api/feeders/secret POSTs to Bearer alv1 auth (#84)
* feat(claim): use Authorization: Bearer alv1 for /api/feeders/secret claim register and claim rotate now POST {"new_secret":...} with the bearer header carrying the auth secret (the new secret on register; the current active secret on rotate). Matches /status, /diagnostics, /config/sync. CLI stubs assert the wire shape directly and the install/upgrade Python stub servers reject missing-bearer + legacy body keys, so a regression to body-auth surfaces as a stub failure rather than silently passing. * test: enforce v2 wire shape in shared claim stub The recovery-path tests share start_claim_server; if claim_rotate ever regressed to body-auth the explicit wire-shape assertion would catch it but every other test would silently keep passing. Move the bearer + slim-body gate into the stub itself. Align install/upgrade stub error codes with the contract fixture and document the fixture's request.uuid as the bearer UUID identifier, not a body field.
1 parent bd0da35 commit 5df55b1

7 files changed

Lines changed: 207 additions & 19 deletions

File tree

scripts/apl-feed/claim.sh

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,14 @@ claim_register() {
7171
backoff=1
7272

7373
while true; do
74-
body="$(printf '{"uuid":"%s","current_secret":null,"new_secret":"%s"}' "$uuid" "$secret")"
74+
# v2 wire shape: Authorization: Bearer alv1.<uuid>.<auth_secret> +
75+
# body {"new_secret":...}. For register there is no prior secret
76+
# to authenticate with — the bearer carries the same value being
77+
# registered ("tautology"). The server ignores the bearer secret
78+
# on the CREATE branch and stores hash(body.new_secret).
79+
body="$(printf '{"new_secret":"%s"}' "$secret")"
7580
set +e
76-
status="$(post_json '/api/feeders/secret' "$body" "$response_file")"
81+
status="$(post_json_bearer "alv1.${uuid}.${secret}" '/api/feeders/secret' "$body" "$response_file")"
7782
curl_rc=$?
7883
set -e
7984

@@ -281,9 +286,16 @@ claim_rotate() {
281286
backoff=1
282287

283288
while true; do
284-
body="$(printf '{"uuid":"%s","current_secret":"%s","new_secret":"%s"}' "$uuid" "$current" "$next")"
289+
# v2 wire shape: Authorization: Bearer alv1.<uuid>.<current> +
290+
# body {"new_secret": next}. Server verifies hash(current) against
291+
# the stored hash for the rotate path. The replay-after-network-
292+
# failure path (server already accepted next; client retries) is
293+
# preserved verbatim — server's NOOP_REPLAY check runs before the
294+
# rotate-current-check, so a stale bearer with the matching body
295+
# new_secret still returns 200.
296+
body="$(printf '{"new_secret":"%s"}' "$next")"
285297
set +e
286-
status="$(post_json '/api/feeders/secret' "$body" "$response_file")"
298+
status="$(post_json_bearer "alv1.${uuid}.${current}" '/api/feeders/secret' "$body" "$response_file")"
287299
curl_rc=$?
288300
set -e
289301

test/contracts/feeder-api-v1.json

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
{
2+
"_doc": "Fixture shape for /api/feeders/secret (DEV-427): request.uuid is the bearer UUID identifier and is NOT a body field. The body sent on the wire is exactly {\"new_secret\": <request.new_secret>}. The Authorization header is Bearer alv1.<request.uuid>.<bearer_secret>. Mirrors the /status fixture shape used elsewhere.",
23
"schema_version": 1,
34
"secret": {
45
"create_success": {
56
"request": {
67
"uuid": "11111111-2222-3333-4444-555555555555",
7-
"current_secret": null,
88
"new_secret": "ABCDEFGHIJKLMNOP"
99
},
10+
"bearer_secret": "ABCDEFGHIJKLMNOP",
1011
"response": {
1112
"status": 201,
1213
"body": {
@@ -17,9 +18,9 @@
1718
"noop_replay": {
1819
"request": {
1920
"uuid": "11111111-2222-3333-4444-555555555555",
20-
"current_secret": null,
2121
"new_secret": "ABCDEFGHIJKLMNOP"
2222
},
23+
"bearer_secret": "ABCDEFGHIJKLMNOP",
2324
"response": {
2425
"status": 200,
2526
"body": {
@@ -30,16 +31,32 @@
3031
"rotate_success": {
3132
"request": {
3233
"uuid": "11111111-2222-3333-4444-555555555555",
33-
"current_secret": "ABCDEFGHIJKLMNOP",
3434
"new_secret": "QRSTUVWXYZ012345"
3535
},
36+
"bearer_secret": "ABCDEFGHIJKLMNOP",
3637
"response": {
3738
"status": 200,
3839
"body": {
3940
"version": 2
4041
}
4142
}
4243
},
44+
"missing_authorization": {
45+
"response": {
46+
"status": 400,
47+
"body": {
48+
"error": "missing_authorization"
49+
}
50+
}
51+
},
52+
"malformed_authorization": {
53+
"response": {
54+
"status": 400,
55+
"body": {
56+
"error": "malformed_authorization"
57+
}
58+
}
59+
},
4360
"invalid_uuid": {
4461
"response": {
4562
"status": 400,

test/script-install.sh

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,40 @@ chmod +x /usr/local/sbin/nc
6262
python3 - <<'PY' &
6363
import http.server
6464
import json
65+
import re
66+
67+
# v2 wire shape (DEV-427): require Authorization: Bearer alv1.<uuid>.<secret>
68+
# and reject legacy body fields. A bare 201 response would let a client
69+
# silently regress to the v1 body shape.
70+
BEARER_RE = re.compile(r"^Bearer alv1\.[0-9a-fA-F-]{32,36}\.[A-Za-z0-9]{1,64}$")
6571
6672
class Handler(http.server.BaseHTTPRequestHandler):
6773
def do_POST(self):
68-
self.rfile.read(int(self.headers.get("Content-Length", 0)))
74+
length = int(self.headers.get("Content-Length", 0))
75+
raw = self.rfile.read(length) if length > 0 else b""
6976
if self.path != "/api/feeders/secret":
7077
self.send_response(404)
7178
self.end_headers()
7279
return
80+
auth = self.headers.get("Authorization", "")
81+
if not BEARER_RE.match(auth):
82+
self.send_response(400)
83+
self.send_header("Content-Type", "application/json")
84+
self.end_headers()
85+
self.wfile.write(json.dumps({"error": "missing_authorization"}).encode())
86+
return
87+
try:
88+
body = json.loads(raw or b"{}")
89+
except Exception:
90+
body = {}
91+
# v2 body must contain ONLY new_secret. current_secret / uuid are
92+
# legacy keys and prove the client never picked up the migration.
93+
if not isinstance(body, dict) or set(body.keys()) != {"new_secret"}:
94+
self.send_response(400)
95+
self.send_header("Content-Type", "application/json")
96+
self.end_headers()
97+
self.wfile.write(json.dumps({"error": "invalid_request"}).encode())
98+
return
7399
self.send_response(201)
74100
self.send_header("Content-Type", "application/json")
75101
self.end_headers()

test/script-upgrade.sh

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,14 +88,37 @@ chmod +x /usr/local/sbin/nc
8888
python3 - <<'PY' &
8989
import http.server
9090
import json
91+
import re
92+
93+
# v2 wire shape (DEV-427): require Authorization: Bearer alv1.<uuid>.<secret>
94+
# and reject legacy body fields.
95+
BEARER_RE = re.compile(r"^Bearer alv1\.[0-9a-fA-F-]{32,36}\.[A-Za-z0-9]{1,64}$")
9196
9297
class Handler(http.server.BaseHTTPRequestHandler):
9398
def do_POST(self):
94-
self.rfile.read(int(self.headers.get("Content-Length", 0)))
99+
length = int(self.headers.get("Content-Length", 0))
100+
raw = self.rfile.read(length) if length > 0 else b""
95101
if self.path != "/api/feeders/secret":
96102
self.send_response(404)
97103
self.end_headers()
98104
return
105+
auth = self.headers.get("Authorization", "")
106+
if not BEARER_RE.match(auth):
107+
self.send_response(400)
108+
self.send_header("Content-Type", "application/json")
109+
self.end_headers()
110+
self.wfile.write(json.dumps({"error": "missing_authorization"}).encode())
111+
return
112+
try:
113+
body = json.loads(raw or b"{}")
114+
except Exception:
115+
body = {}
116+
if not isinstance(body, dict) or set(body.keys()) != {"new_secret"}:
117+
self.send_response(400)
118+
self.send_header("Content-Type", "application/json")
119+
self.end_headers()
120+
self.wfile.write(json.dumps({"error": "invalid_request"}).encode())
121+
return
99122
self.send_response(201)
100123
self.send_header("Content-Type", "application/json")
101124
self.end_headers()

test/test_apl_feed_cli.bats

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ setup() {
1414
touch "$ROOT_DIR/var/lib/airplanes/diagnostics-last-success"
1515
MOCK_PORT_FILE="$(mktemp)"
1616
MOCK_PID_FILE="$(mktemp)"
17+
# Mock servers append one "PATH<TAB>AUTH<TAB>BODY" line per POST here,
18+
# so tests can pin the v2 wire shape (Authorization: Bearer alv1.X.Y +
19+
# slim {"new_secret": ...} body for /api/feeders/secret).
20+
MOCK_REQ_FILE="$(mktemp)"
1721

1822
# Stub external commands `apl-feed status` calls so the result-text
1923
# assertions don't depend on whether the host runner has systemctl,
@@ -47,7 +51,7 @@ STUB
4751
teardown() {
4852
stop_mock_server || true
4953
rm -rf "$ROOT_DIR" "$STUB_BIN_DIR"
50-
rm -f "$MOCK_PORT_FILE" "$MOCK_PID_FILE"
54+
rm -f "$MOCK_PORT_FILE" "$MOCK_PID_FILE" "$MOCK_REQ_FILE"
5155
}
5256

5357
stop_mock_server() {
@@ -106,24 +110,53 @@ start_claim_server() {
106110
local pending_version="$6"
107111
local port_file="$MOCK_PORT_FILE"
108112
local pid_file="$MOCK_PID_FILE"
113+
local req_file="$MOCK_REQ_FILE"
109114
python3 - "$port_file" "$secret_status" "$secret_body" \
110-
"$active_secret" "$active_version" "$pending_secret" "$pending_version" <<'PY' &
111-
import http.server, json, sys
115+
"$active_secret" "$active_version" "$pending_secret" "$pending_version" \
116+
"$req_file" <<'PY' &
117+
import http.server, json, re, sys
112118
port_file = sys.argv[1]
113119
secret_status = int(sys.argv[2])
114120
secret_body = sys.argv[3]
115121
active_secret = sys.argv[4]
116122
active_version = sys.argv[5]
117123
pending_secret = sys.argv[6]
118124
pending_version = sys.argv[7]
125+
req_file = sys.argv[8]
126+
127+
# v2 wire-shape gate (DEV-427): the shared claim stub used by every
128+
# rotation/recovery test enforces Bearer + slim body for /secret. Without
129+
# this, a regression that flips claim_rotate back to body-auth would
130+
# silently pass every rotation test except the one that explicitly
131+
# inspects MOCK_REQ_FILE.
132+
BEARER_RE = re.compile(r"^Bearer alv1\.[0-9a-fA-F-]{32,36}\.[A-Za-z0-9]{1,64}$")
133+
119134
class H(http.server.BaseHTTPRequestHandler):
120135
def do_POST(self):
121136
raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
122137
try:
123138
body = json.loads(raw.decode() or "{}")
124139
except Exception:
125140
body = {}
141+
# Record (path, auth, body) for test-side wire-shape assertions.
142+
auth = self.headers.get("Authorization", "")
143+
with open(req_file, "a") as f:
144+
f.write(f"{self.path}\t{auth}\t{raw.decode('utf-8', errors='replace')}\n")
126145
if self.path == "/api/feeders/secret":
146+
# Enforce v2 wire shape at the stub. Anything else means the
147+
# client has regressed back to v1 body-auth.
148+
if not BEARER_RE.match(auth):
149+
self.send_response(400)
150+
self.send_header("Content-Type", "application/json")
151+
self.end_headers()
152+
self.wfile.write(json.dumps({"error": "missing_authorization"}).encode())
153+
return
154+
if not isinstance(body, dict) or set(body.keys()) != {"new_secret"}:
155+
self.send_response(400)
156+
self.send_header("Content-Type", "application/json")
157+
self.end_headers()
158+
self.wfile.write(json.dumps({"error": "invalid_request"}).encode())
159+
return
127160
self.send_response(secret_status)
128161
self.send_header("Content-Type", "application/json")
129162
self.end_headers()
@@ -281,6 +314,31 @@ EOF
281314
[ "$(cat "$ROOT_DIR/etc/airplanes/feeder-claim-secret")" != "ABCDEFGHIJKLMNOP" ]
282315
}
283316

317+
@test "claim rotate POST sends v2 bearer with current secret + slim body" {
318+
# Pin the v2 wire shape for rotation (DEV-427): bearer carries the
319+
# *current* (pre-rotation) secret, body has only new_secret with the
320+
# next value. No legacy current_secret / uuid keys in the body.
321+
echo "ABCDEFGHIJKLMNOP" > "$ROOT_DIR/etc/airplanes/feeder-claim-secret"
322+
chmod 600 "$ROOT_DIR/etc/airplanes/feeder-claim-secret"
323+
start_claim_server 200 '{"version": 2}' \
324+
"ABCDEFGHIJKLMNOP" 1 "" 0
325+
326+
run "$SCRIPT" claim rotate --root "$ROOT_DIR" --server-url "$(mock_url)"
327+
[ "$status" -eq 0 ]
328+
# Filter to the /secret POST line; /status probes are not relevant here
329+
# but may also appear in the capture if the rotate flow probes.
330+
secret_line="$(grep -F $'/api/feeders/secret\t' "$MOCK_REQ_FILE" | head -1)"
331+
[ -n "$secret_line" ]
332+
# Bearer = alv1.<uuid>.<current_secret>.
333+
auth="$(printf '%s' "$secret_line" | awk -F'\t' '{print $2}')"
334+
[[ "$auth" = "Bearer alv1.11111111-2222-3333-4444-555555555555.ABCDEFGHIJKLMNOP" ]]
335+
# Body = {"new_secret":"<16-char>"} with no other keys.
336+
body="$(printf '%s' "$secret_line" | awk -F'\t' '{print $3}')"
337+
[[ "$body" =~ ^\{\"new_secret\":\"[A-Z0-9]{16}\"\}$ ]]
338+
[[ ! "$body" =~ current_secret ]]
339+
[[ ! "$body" =~ \"uuid\" ]]
340+
}
341+
284342
@test "claim rotate finalizes pending after lost response" {
285343
echo "ABCDEFGHIJKLMNOP" > "$ROOT_DIR/etc/airplanes/feeder-claim-secret"
286344
echo "QRSTUVWXYZ012345" > "$ROOT_DIR/etc/airplanes/feeder-claim-secret.pending"

test/test_claim_register.bats

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,36 +12,48 @@ setup() {
1212
MOCK_RESP_FILE="$(mktemp)"
1313
MOCK_PORT_FILE="$(mktemp)"
1414
MOCK_PID_FILE="$(mktemp)"
15+
# Capture of the most recent request the mock server received. Each
16+
# successful POST overwrites this file with the parsed Authorization
17+
# header on line 1 and the request body on line 2. Tests grep this
18+
# to assert v2 wire shape (DEV-427).
19+
MOCK_REQ_FILE="$(mktemp)"
1520
}
1621

1722
teardown() {
1823
stop_mock_server || true
1924
rm -rf "$ROOT_DIR"
20-
rm -f "$MOCK_RESP_FILE" "$MOCK_PORT_FILE" "$MOCK_PID_FILE"
25+
rm -f "$MOCK_RESP_FILE" "$MOCK_PORT_FILE" "$MOCK_PID_FILE" "$MOCK_REQ_FILE"
2126
}
2227

2328
# Inline mock HTTP server. Reads its (status, body) response from
2429
# $MOCK_RESP_FILE on each request, so a test can sequence responses by
2530
# rewriting the file between polls. For one-shot tests we just write once
26-
# before starting the server.
31+
# before starting the server. Records the inbound Authorization header
32+
# and body to $MOCK_REQ_FILE so tests can pin the v2 wire shape.
2733
start_mock_server() {
2834
local port_file="$MOCK_PORT_FILE"
2935
local resp_file="$MOCK_RESP_FILE"
36+
local req_file="$MOCK_REQ_FILE"
3037
local pid_file="$MOCK_PID_FILE"
31-
python3 - "$port_file" "$resp_file" <<'PY' &
38+
python3 - "$port_file" "$resp_file" "$req_file" <<'PY' &
3239
import http.server, json, sys
33-
port_file, resp_file = sys.argv[1], sys.argv[2]
40+
port_file, resp_file, req_file = sys.argv[1], sys.argv[2], sys.argv[3]
3441
class H(http.server.BaseHTTPRequestHandler):
3542
def do_POST(self):
3643
with open(resp_file) as f:
3744
spec = json.load(f)
38-
self.rfile.read(int(self.headers.get("Content-Length", 0)))
45+
length = int(self.headers.get("Content-Length", 0))
46+
body = self.rfile.read(length) if length > 0 else b""
47+
auth = self.headers.get("Authorization", "")
48+
with open(req_file, "w") as f:
49+
f.write(f"AUTH: {auth}\n")
50+
f.write(f"BODY: {body.decode('utf-8', errors='replace')}\n")
3951
self.send_response(spec["status"])
4052
ct = spec.get("content_type", "application/json")
4153
self.send_header("Content-Type", ct)
4254
self.end_headers()
43-
body = spec.get("body", {})
44-
out = body if isinstance(body, str) else json.dumps(body)
55+
resp_body = spec.get("body", {})
56+
out = resp_body if isinstance(resp_body, str) else json.dumps(resp_body)
4557
self.wfile.write(out.encode())
4658
def log_message(self, *a, **kw): pass
4759
@@ -176,6 +188,29 @@ mock_url() {
176188
[[ "$output" =~ "SUCCESS" ]]
177189
}
178190

191+
@test "register POST sends v2 bearer + slim body (no legacy fields)" {
192+
# Pin the v2 wire shape (DEV-427): Authorization carries the bearer,
193+
# body has only new_secret. Legacy current_secret / uuid keys must
194+
# be absent — a v2 server rejects them with 400 invalid_request.
195+
write_contract_response secret create_success
196+
start_mock_server
197+
run "$SCRIPT" claim register --root "$ROOT_DIR" --server-url "$(mock_url)"
198+
[ "$status" -eq 0 ]
199+
# Authorization header is alv1.<uuid>.<secret>; the register tautology
200+
# means the bearer secret equals the body new_secret.
201+
grep -E '^AUTH: Bearer alv1\.11111111-2222-3333-4444-555555555555\.[A-Z0-9]{16}$' "$MOCK_REQ_FILE"
202+
# Body shape: {"new_secret":"..."} with no other keys.
203+
body_line="$(grep '^BODY: ' "$MOCK_REQ_FILE" | head -1 | sed 's/^BODY: //')"
204+
[[ "$body_line" =~ ^\{\"new_secret\":\"[A-Z0-9]{16}\"\}$ ]]
205+
[[ ! "$body_line" =~ current_secret ]]
206+
[[ ! "$body_line" =~ \"uuid\" ]]
207+
# Register tautology: the bearer secret and the body new_secret are
208+
# the same value.
209+
auth_secret="$(grep '^AUTH: ' "$MOCK_REQ_FILE" | sed -E 's/.*alv1\.[^.]+\.([A-Z0-9]+)$/\1/')"
210+
body_secret="$(echo "$body_line" | sed -E 's/.*"new_secret":"([^"]+)".*/\1/')"
211+
[ "$auth_secret" = "$body_secret" ]
212+
}
213+
179214
@test "200 NOOP_REPLAY exits 0 (treated as success)" {
180215
write_contract_response secret noop_replay
181216
start_mock_server

test/test_wire_endpoints.bats

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,20 @@ setup() {
4040
@test "claim CLI posts to /api/feeders/secret" {
4141
grep -q "/api/feeders/secret" "$REPO_ROOT/scripts/apl-feed/claim.sh"
4242
}
43+
44+
@test "claim CLI uses post_json_bearer (v2 wire shape) for /api/feeders/secret" {
45+
# DEV-427 migrated /secret from body-auth to bearer. claim.sh must
46+
# ship every /secret call through post_json_bearer; a post_json call
47+
# to /secret would send the v1 body shape and the server now returns
48+
# 400 missing_authorization.
49+
[ "$(grep -cE "post_json_bearer .+ '/api/feeders/secret'" \
50+
"$REPO_ROOT/scripts/apl-feed/claim.sh")" -ge 2 ]
51+
# Negative guard: no legacy post_json call to /secret.
52+
[ "$(grep -cE "post_json '/api/feeders/secret'" \
53+
"$REPO_ROOT/scripts/apl-feed/claim.sh")" -eq 0 ]
54+
# Negative guard: no legacy body-field current_secret in the wire
55+
# payload. (The string appears once in a user-facing error message
56+
# explaining a 409 rotation_rejected response — that's not a body
57+
# key. We match the JSON-key shape `"current_secret":` only.)
58+
[ "$(grep -cE '"current_secret":' "$REPO_ROOT/scripts/apl-feed/claim.sh")" -eq 0 ]
59+
}

0 commit comments

Comments
 (0)