Establish the live state and find a rollout precondition for G10
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 45s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 45s
G10 waits on custody and platform owners and cannot close from here. What was doable: verify the handoffs actually went out, replace a remembered live state with an observed one, and find out whether main is safe to deploy. The last question found a defect in this repository's own recent work. Handoffs verified independently rather than trusted: all seven messages are in the hub with receipt ids. This gap was reopened once for claimed-but-unsent delivery, so the claim deserved the same scrutiny. Live state read from the cluster read-only: image main-153258b, only the Qonto secret materialized so the approval clients remain unprovisioned, four registered clients, no tenantEngine block. That also corrects an earlier claim of mine -- the deployed config sets userOU explicitly, so the KEY-WP-0023 default fix was never a production issue. The precondition: KEY-WP-0019 discovers the expected issuer from authelia.tokenBaseURL, and the deployed Authelia derives its advertised issuer from the request Host, advertising the in-cluster address to KeyCape and the browser-facing one to browsers. Verification fails closed, so a mismatch breaks every human login and looks like a broken login rather than a misconfiguration. Which value the token carries needs a real login against production to settle and was not determined here. Two mitigations: docs/operations.md documents pinning authelia.issuer and jwksUrl, with the curl that reveals what the provider advertises for a given Host; and the authentication failure event now carries a specific reason, so id_token_issuer_mismatch is distinguishable from a signature failure or an unreachable key set. The browser still learns nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NV9oijZukGyGbRQGGKnK4P Assistant: claude-code Assistant-Model: opus Assistant-Process: 713576@bnt-lap001 Assistant-Session: 384c511d-9bce-4cb8-a676-2aef6c0c8df6
This commit is contained in:
parent
64942639ad
commit
7dda967c27
6 changed files with 293 additions and 7 deletions
|
|
@ -94,6 +94,37 @@ providers either; upstream ID tokens are verified cryptographically instead, so
|
||||||
that assurance does not depend on the network being what we believe it is
|
that assurance does not depend on the network being what we believe it is
|
||||||
(KEY-WP-0019).
|
(KEY-WP-0019).
|
||||||
|
|
||||||
|
## Upstream issuer pinning (read before rolling out KEY-WP-0019)
|
||||||
|
|
||||||
|
KeyCape verifies upstream ID tokens against the issuer the provider advertises,
|
||||||
|
discovered server-side from `authelia.tokenBaseURL`. Some providers — Authelia
|
||||||
|
among them — derive the advertised issuer from the **request Host**, so the value
|
||||||
|
KeyCape learns over an in-cluster service address is not the value minted into
|
||||||
|
tokens issued for the browser-facing host.
|
||||||
|
|
||||||
|
Where that is true, pin it explicitly:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
authelia:
|
||||||
|
tokenBaseURL: "http://authelia.sso.svc.cluster.local:9091"
|
||||||
|
issuer: "https://auth.coulomb.social" # exactly the iss claim in ID tokens
|
||||||
|
jwksUrl: "http://authelia.sso.svc.cluster.local:9091/jwks.json"
|
||||||
|
```
|
||||||
|
|
||||||
|
Verification fails closed, so a mismatch means **every human login fails** — and
|
||||||
|
it looks like a broken login rather than a configuration error. Check the
|
||||||
|
issuer before rolling out, not after. The failure is diagnosable: the
|
||||||
|
authentication failure event carries `error_type=id_token_issuer_mismatch`, as
|
||||||
|
distinct from `id_token_signature` or `provider_keys_unavailable`.
|
||||||
|
|
||||||
|
Confirm the value with the Host the provider will actually see:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s -H "Host: auth.coulomb.social" \
|
||||||
|
http://authelia.sso.svc.cluster.local:9091/.well-known/openid-configuration \
|
||||||
|
| jq -r .issuer
|
||||||
|
```
|
||||||
|
|
||||||
## What is not claimed
|
## What is not claimed
|
||||||
|
|
||||||
No resource-efficiency or throughput bounds are asserted here. Nothing in this
|
No resource-efficiency or throughput bounds are asserted here. Nothing in this
|
||||||
|
|
|
||||||
|
|
@ -464,6 +464,31 @@ register the real callback, deploy and verify the new contracts, reconcile token
|
||||||
types at consumer boundaries, and retain handoff receipts. Repo-local source
|
types at consumer boundaries, and retain handoff receipts. Repo-local source
|
||||||
changes cannot alone establish these outcomes.
|
changes cannot alone establish these outcomes.
|
||||||
|
|
||||||
|
**Status 2026-09-08 (KEY-WP-0027): still open, and correctly so.** The handoff
|
||||||
|
hygiene half is discharged: all seven messages are present in the hub with
|
||||||
|
receipt ids, verified independently rather than taken on trust, since this gap
|
||||||
|
was reopened once for exactly that reason. No replies yet.
|
||||||
|
|
||||||
|
Live state observed read-only rather than remembered: image `main-153258b`,
|
||||||
|
only the Qonto secret materialized (so the two approval clients remain
|
||||||
|
unprovisioned), four registered clients, no `tenantEngine` block. An earlier
|
||||||
|
claim of mine is corrected there: the deployed config sets `userOU: "ou=people"`
|
||||||
|
explicitly, so the KEY-WP-0023 default fix was never a production issue.
|
||||||
|
|
||||||
|
**Rollout precondition found in this repository's own work.** Upstream
|
||||||
|
verification (KEY-WP-0019) discovers the expected issuer from
|
||||||
|
`authelia.tokenBaseURL`, and the deployed Authelia derives its advertised issuer
|
||||||
|
from the request Host — in-cluster it advertises
|
||||||
|
`http://authelia.sso.svc.cluster.local:9091`, browser-facing
|
||||||
|
`http://auth.coulomb.social`. Verification fails closed, so a mismatch breaks
|
||||||
|
every human login and presents as a broken login rather than a misconfiguration.
|
||||||
|
Which value the token carries was not settled, because doing so needs a real
|
||||||
|
login against production. `docs/operations.md` documents pinning
|
||||||
|
`authelia.issuer`/`jwksUrl`, and the failure now reports a specific
|
||||||
|
`id_token_issuer_mismatch` reason so it is diagnosable in seconds.
|
||||||
|
|
||||||
|
The rest is owner work and stays open.
|
||||||
|
|
||||||
## Deliberate exclusions are not defects
|
## Deliberate exclusions are not defects
|
||||||
|
|
||||||
INTENT excludes general-purpose IAM, weakened flows and expanded-mode operations.
|
INTENT excludes general-purpose IAM, weakened flows and expanded-mode operations.
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,10 @@ func (a *AutheliaAdapter) HandleCallback(ctx context.Context, params domain.Call
|
||||||
EventType: telemetry.EventAuthFailure,
|
EventType: telemetry.EventAuthFailure,
|
||||||
Endpoint: "/api/oidc/token",
|
Endpoint: "/api/oidc/token",
|
||||||
Result: "failure",
|
Result: "failure",
|
||||||
ErrorType: "id_token_verification_error",
|
// Name which check failed: an issuer mismatch from a provider that
|
||||||
|
// derives its issuer from the request Host is a misconfiguration,
|
||||||
|
// not an attack, and is indistinguishable from one without this.
|
||||||
|
ErrorType: FailureReason(err),
|
||||||
})
|
})
|
||||||
return nil, domain.ErrAuthFailed
|
return nil, domain.ErrAuthFailed
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package authelia
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
@ -39,6 +40,56 @@ type idTokenVerifier struct {
|
||||||
keys jose.KeySet
|
keys jose.KeySet
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verification failure reasons, reported through telemetry so an operator can
|
||||||
|
// tell a misconfiguration from an attack. They never reach the browser.
|
||||||
|
//
|
||||||
|
// The issuer case is worth naming separately: a provider that derives its
|
||||||
|
// advertised issuer from the request Host advertises one value to KeyCape (which
|
||||||
|
// fetches server-side) and mints another into tokens (issued for the
|
||||||
|
// browser-facing host). That fails closed and looks exactly like a broken login
|
||||||
|
// unless the log says which check failed (KEY-WP-0027).
|
||||||
|
const (
|
||||||
|
ReasonIssuerMismatch = "id_token_issuer_mismatch"
|
||||||
|
ReasonAudienceMismatch = "id_token_audience_mismatch"
|
||||||
|
ReasonExpired = "id_token_expired"
|
||||||
|
ReasonWindow = "id_token_validity_window"
|
||||||
|
ReasonSignature = "id_token_signature"
|
||||||
|
ReasonProviderMetadata = "provider_metadata_unavailable"
|
||||||
|
ReasonKeysUnavailable = "provider_keys_unavailable"
|
||||||
|
ReasonVerification = "id_token_verification_error"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
errIssuerMismatch = errors.New("authelia: id_token issuer mismatch")
|
||||||
|
errAudienceMismatch = errors.New("authelia: id_token audience does not include this client")
|
||||||
|
errExpired = errors.New("authelia: id_token expired")
|
||||||
|
errWindow = errors.New("authelia: id_token validity window is not sane")
|
||||||
|
)
|
||||||
|
|
||||||
|
// FailureReason classifies a verification error for telemetry.
|
||||||
|
func FailureReason(err error) string {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return ""
|
||||||
|
case errors.Is(err, errIssuerMismatch):
|
||||||
|
return ReasonIssuerMismatch
|
||||||
|
case errors.Is(err, errAudienceMismatch):
|
||||||
|
return ReasonAudienceMismatch
|
||||||
|
case errors.Is(err, errExpired):
|
||||||
|
return ReasonExpired
|
||||||
|
case errors.Is(err, errWindow):
|
||||||
|
return ReasonWindow
|
||||||
|
case errors.Is(err, jose.ErrVerification):
|
||||||
|
return ReasonSignature
|
||||||
|
case strings.Contains(err.Error(), "provider metadata"):
|
||||||
|
return ReasonProviderMetadata
|
||||||
|
case strings.Contains(err.Error(), "jwks"):
|
||||||
|
return ReasonKeysUnavailable
|
||||||
|
default:
|
||||||
|
return ReasonVerification
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// leeway absorbs ordinary clock skew between KeyCape and the provider. It is
|
// leeway absorbs ordinary clock skew between KeyCape and the provider. It is
|
||||||
// small on purpose: it is a tolerance for imperfect clocks, not a grace period.
|
// small on purpose: it is a tolerance for imperfect clocks, not a grace period.
|
||||||
const leeway = 30 * time.Second
|
const leeway = 30 * time.Second
|
||||||
|
|
@ -87,26 +138,26 @@ func (v *idTokenVerifier) Verify(ctx context.Context, idToken string) (map[strin
|
||||||
// for us, by the provider we expect, and now.
|
// for us, by the provider we expect, and now.
|
||||||
func (v *idTokenVerifier) checkClaims(claims map[string]interface{}, issuer string) error {
|
func (v *idTokenVerifier) checkClaims(claims map[string]interface{}, issuer string) error {
|
||||||
if got, _ := claims["iss"].(string); got != issuer {
|
if got, _ := claims["iss"].(string); got != issuer {
|
||||||
return fmt.Errorf("authelia: id_token issuer mismatch")
|
return errIssuerMismatch
|
||||||
}
|
}
|
||||||
if !audienceContains(claims["aud"], v.clientID) {
|
if !audienceContains(claims["aud"], v.clientID) {
|
||||||
return fmt.Errorf("authelia: id_token audience does not include this client")
|
return errAudienceMismatch
|
||||||
}
|
}
|
||||||
exp, hasExp := numericClaim(claims, "exp")
|
exp, hasExp := numericClaim(claims, "exp")
|
||||||
iat, hasIat := numericClaim(claims, "iat")
|
iat, hasIat := numericClaim(claims, "iat")
|
||||||
if !hasExp || !hasIat {
|
if !hasExp || !hasIat {
|
||||||
return fmt.Errorf("authelia: id_token missing exp or iat")
|
return fmt.Errorf("%w: missing exp or iat", errWindow)
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
if now.After(time.Unix(int64(exp), 0).Add(leeway)) {
|
if now.After(time.Unix(int64(exp), 0).Add(leeway)) {
|
||||||
return fmt.Errorf("authelia: id_token expired")
|
return errExpired
|
||||||
}
|
}
|
||||||
if time.Unix(int64(iat), 0).After(now.Add(leeway)) || iat >= exp {
|
if time.Unix(int64(iat), 0).After(now.Add(leeway)) || iat >= exp {
|
||||||
return fmt.Errorf("authelia: id_token validity window is not sane")
|
return errWindow
|
||||||
}
|
}
|
||||||
if nbf, hasNbf := numericClaim(claims, "nbf"); hasNbf {
|
if nbf, hasNbf := numericClaim(claims, "nbf"); hasNbf {
|
||||||
if time.Unix(int64(nbf), 0).After(now.Add(leeway)) {
|
if time.Unix(int64(nbf), 0).After(now.Add(leeway)) {
|
||||||
return fmt.Errorf("authelia: id_token is not yet valid")
|
return fmt.Errorf("%w: not yet valid", errWindow)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
|
|
||||||
"keycape/internal/adapters/authelia"
|
"keycape/internal/adapters/authelia"
|
||||||
"keycape/internal/domain"
|
"keycape/internal/domain"
|
||||||
|
"keycape/internal/server/telemetry"
|
||||||
)
|
)
|
||||||
|
|
||||||
// KEY-WP-0019-T03 — upstream ID tokens are verified independently of transport.
|
// KEY-WP-0019-T03 — upstream ID tokens are verified independently of transport.
|
||||||
|
|
@ -210,3 +211,66 @@ func smallKeyJWKS(t *testing.T) string {
|
||||||
base64.RawURLEncoding.EncodeToString(small.PublicKey.N.Bytes()),
|
base64.RawURLEncoding.EncodeToString(small.PublicKey.N.Bytes()),
|
||||||
base64.RawURLEncoding.EncodeToString(big.NewInt(int64(small.PublicKey.E)).Bytes()))
|
base64.RawURLEncoding.EncodeToString(big.NewInt(int64(small.PublicKey.E)).Bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Telemetry must name which check failed. A provider that derives its advertised
|
||||||
|
// issuer from the request Host advertises one value to KeyCape and mints another
|
||||||
|
// into tokens; that fails closed and is indistinguishable from an attack unless
|
||||||
|
// the reason is recorded (KEY-WP-0027).
|
||||||
|
// captureEmitter records emitted telemetry events.
|
||||||
|
type captureEmitter struct{ events []telemetry.Event }
|
||||||
|
|
||||||
|
func (c *captureEmitter) Emit(_ context.Context, ev telemetry.Event) {
|
||||||
|
c.events = append(c.events, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailureReasonsAreDistinguishable(t *testing.T) {
|
||||||
|
valid := map[string]interface{}{"preferred_username": "alice"}
|
||||||
|
cases := map[string]struct {
|
||||||
|
provider *provider
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
"issuer mismatch": {&provider{
|
||||||
|
jwks: testJWKS(nil),
|
||||||
|
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "iss": "https://elsewhere.example"}, testKeyID, nil),
|
||||||
|
}, authelia.ReasonIssuerMismatch},
|
||||||
|
"audience mismatch": {&provider{
|
||||||
|
jwks: testJWKS(nil),
|
||||||
|
idToken: signIDToken(map[string]interface{}{"preferred_username": "alice", "aud": "another-client"}, testKeyID, nil),
|
||||||
|
}, authelia.ReasonAudienceMismatch},
|
||||||
|
"expired": {&provider{
|
||||||
|
jwks: testJWKS(nil),
|
||||||
|
idToken: signIDToken(map[string]interface{}{
|
||||||
|
"preferred_username": "alice",
|
||||||
|
"iat": time.Now().Add(-2 * time.Hour).Unix(),
|
||||||
|
"exp": time.Now().Add(-time.Hour).Unix(),
|
||||||
|
}, testKeyID, nil),
|
||||||
|
}, authelia.ReasonExpired},
|
||||||
|
"signature": {&provider{
|
||||||
|
jwks: testJWKS(nil),
|
||||||
|
idToken: signIDToken(valid, "unpublished-key", nil),
|
||||||
|
}, authelia.ReasonSignature},
|
||||||
|
"keys unavailable": {&provider{
|
||||||
|
jwksErr: errors.New("connection refused"),
|
||||||
|
idToken: signIDToken(valid, testKeyID, nil),
|
||||||
|
}, authelia.ReasonKeysUnavailable},
|
||||||
|
}
|
||||||
|
for name, tc := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
adapter := authelia.New(testConfig(), tc.provider)
|
||||||
|
emitter := &captureEmitter{}
|
||||||
|
ctx := telemetry.WithEmitter(context.Background(), emitter)
|
||||||
|
if _, err := adapter.HandleCallback(ctx, domain.CallbackParams{Code: "c", State: "s"}); !errors.Is(err, domain.ErrAuthFailed) {
|
||||||
|
t.Fatalf("expected ErrAuthFailed, got %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, ev := range emitter.events {
|
||||||
|
if ev.ErrorType == tc.want {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("no event with ErrorType %q; got %v", tc.want, emitter.events)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
112
workplans/KEY-WP-0027-rollout-readiness-and-live-state.md
Normal file
112
workplans/KEY-WP-0027-rollout-readiness-and-live-state.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
---
|
||||||
|
id: KEY-WP-0027
|
||||||
|
type: workplan
|
||||||
|
title: "Establish the live state and the rollout gap for G10"
|
||||||
|
domain: infotech
|
||||||
|
repo: key-cape
|
||||||
|
status: finished
|
||||||
|
owner: claude
|
||||||
|
topic_slug: rollout-readiness-and-live-state
|
||||||
|
created: "2026-09-08"
|
||||||
|
updated: "2026-09-08"
|
||||||
|
---
|
||||||
|
|
||||||
|
G10 cannot be closed from this repository: it waits on named custody and platform
|
||||||
|
owners. What was doable was to stop guessing about the live state, verify the
|
||||||
|
handoffs actually went out, and find out whether main is safe to roll out. The
|
||||||
|
last question turned up a defect in this repository's own recent work.
|
||||||
|
|
||||||
|
## Verify the handoff receipts independently
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0027-T01
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
KEY-WP-0009 was reopened once because delivery was claimed without receipts, so
|
||||||
|
the claim that it has now been sent deserved the same scrutiny rather than being
|
||||||
|
taken on trust. `GET /messages/?from_agent=key-cape` shows all seven present and
|
||||||
|
dated 2026-09-08T08:21Z: the four KEY-WP-0009 handoffs (netkingdom,
|
||||||
|
secrets-engine, ops-warden, railiance-platform) and the three admission requests
|
||||||
|
(railiance-platform custody, approval-engine human callback, ops-warden token
|
||||||
|
split). No replies had arrived at the time of writing, which is expected hours
|
||||||
|
after sending and is not itself a blocker to record.
|
||||||
|
|
||||||
|
## Record the live state as observed, not as remembered
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0027-T02
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
Read-only cluster inspection, `sso` namespace, 2026-09-08:
|
||||||
|
|
||||||
|
- image `forgejo.coulomb.social/coulomb/key-cape:main-153258b`, predating every
|
||||||
|
change from KEY-WP-0016 onward;
|
||||||
|
- environment carries `KEYCAPE_RAPP_QONTO_CLIENT_SECRET` only, so the two
|
||||||
|
approval clients are still not materialized — KEY-WP-0013-T02 remains correctly
|
||||||
|
blocked;
|
||||||
|
- four registered clients: `demo-app`, `netkingdom-bootstrap-console` and
|
||||||
|
`openbao-admin` (public, authorization_code) and `rapp-qonto-client`
|
||||||
|
(confidential, client_credentials);
|
||||||
|
- no `tenantEngine` block, so KEY-WP-0024 is inert on rollout.
|
||||||
|
|
||||||
|
**Correction to an earlier claim of mine.** I reported the `ou=people` fix
|
||||||
|
(KEY-WP-0023) as a production-relevant correction to human login. The deployed
|
||||||
|
config sets `userOU: "ou=people"` explicitly, so production was never affected.
|
||||||
|
The fix matters for anyone relying on the default — the dev stack, the scenario
|
||||||
|
scripts, a fresh deployment — not for this one.
|
||||||
|
|
||||||
|
## Find out whether main is safe to roll out
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0027-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
**Defect found in KEY-WP-0019, before it shipped.** Upstream verification
|
||||||
|
discovers the expected issuer server-side from `authelia.tokenBaseURL`. The
|
||||||
|
deployed Authelia derives its advertised issuer from the request Host, confirmed
|
||||||
|
directly:
|
||||||
|
|
||||||
|
| Host presented | issuer advertised |
|
||||||
|
| --- | --- |
|
||||||
|
| `authelia.sso.svc.cluster.local:9091` | `http://authelia.sso.svc.cluster.local:9091` |
|
||||||
|
| `auth.coulomb.social` | `http://auth.coulomb.social` |
|
||||||
|
|
||||||
|
KeyCape fetches over the in-cluster address, so it would expect the first value
|
||||||
|
while tokens minted for the browser-facing host carry the second. Verification
|
||||||
|
fails closed, so if they differ **every human login fails after rollout**, and it
|
||||||
|
presents as a broken login rather than a configuration error.
|
||||||
|
|
||||||
|
Which value the ID token actually carries could not be settled from here without
|
||||||
|
performing a real login against production Authelia, which was not done. The risk
|
||||||
|
is therefore recorded as a rollout precondition, not as a confirmed breakage.
|
||||||
|
|
||||||
|
Two mitigations, both in this repository:
|
||||||
|
|
||||||
|
- `docs/operations.md` documents pinning `authelia.issuer` and `authelia.jwksUrl`
|
||||||
|
explicitly, with the one-line `curl` that reveals the value the provider will
|
||||||
|
advertise for a given Host. The overrides already existed; nothing said when
|
||||||
|
they were needed.
|
||||||
|
- The failure is now diagnosable. The authentication failure event carries a
|
||||||
|
specific reason — `id_token_issuer_mismatch`, distinct from
|
||||||
|
`id_token_signature`, `provider_keys_unavailable` and the rest — so an operator
|
||||||
|
can tell a misconfiguration from an attack. Five cases cover the mapping. The
|
||||||
|
browser still learns nothing.
|
||||||
|
|
||||||
|
## Record what each owner owes
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0027-T04
|
||||||
|
status: done
|
||||||
|
priority: medium
|
||||||
|
```
|
||||||
|
|
||||||
|
G10's status now names the live state, the outstanding requests with their
|
||||||
|
receipt ids, and the issuer precondition that must be checked before main is
|
||||||
|
deployed. G10 stays open: nothing here admits custody, registers the human
|
||||||
|
callback, or reconciles the OpenBao-token boundary, and no repo-local change can.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue