toga-ai 1.0.812 → 1.0.813

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.
@@ -15,6 +15,7 @@
15
15
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki |
16
16
  | [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the |
17
17
  | [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` centralizes AWS SDK S3 usage for the 2.0 stack so the `S3Client` never leaks into workers or app code. |
18
+ | [_Cloud Secrets Manager helper (_Cloud::getSecret)](features/cloud-secrets-manager.md) | `_Cloud::getSecret($secretName, $awsRegion = null): ?array` reads a value from **AWS Secrets Manager** for the 2.0 stack. |
18
19
  | [_Component_*/_Model_* project-namespace registration (autoloader) & backslash-qualify traps](features/component-model-namespace-registration.md) | Every **project-local** `_Component_*` and `_Model_*` class in a 2.0 app **must declare the project namespace** at the top of the file: ```php namespace <NAMESP |
19
20
  | [_Config group access — the two-argument "optional" form does NOT rescue a missing GROUP](features/config-group-access.md) | `_Config::<group>('<property>')` reads a value out of the active `Config/<ENVIRONMENT>.ini`, and **it throws when the requested group is absent from the ini.** |
20
21
  | [Core.Domains — the app host registry (and why you cannot derive a host)](features/core-domains-app-host-registry.md) | `Core.Domains` is the **authoritative registry of which host serves which client's app in which environment**. |
@@ -0,0 +1,71 @@
1
+ ---
2
+ title: _Cloud Secrets Manager helper (_Cloud::getSecret)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-15
10
+ owners: ["jcardinal"]
11
+ files:
12
+ - _underscore/Cloud.php
13
+ related:
14
+ - ./cloud-s3-helpers.md
15
+ - ../../saml/workflows/rotating-the-saml-sp-certificate.md
16
+ - ../../saml/architecture.md
17
+ ---
18
+
19
+ ## Summary
20
+
21
+ `_Cloud::getSecret($secretName, $awsRegion = null): ?array` reads a value from **AWS Secrets
22
+ Manager** for the 2.0 stack. It lets code pull secret material (SSL certs/keys, credentials) at
23
+ request time instead of shipping it on disk or in `production.ini`. First consumer: the SAML
24
+ gateway's `/acs`, which builds SP encryption credentials from Secrets Manager (see the SAML SP
25
+ certificate rotation workflow).
26
+
27
+ ## Key files / entry points
28
+
29
+ - `_underscore/Cloud.php` — `_Cloud::getSecret(string $secretName, ?string $awsRegion = null): ?array`.
30
+
31
+ ## How it works
32
+
33
+ 1. Builds a `SecretsManagerClient` and calls `GetSecretValue` for `$secretName`.
34
+ 2. **Auth is the AWS default provider chain** — the EC2 instance role in prod, a local
35
+ profile/env in dev. It deliberately does **NOT** use the shared static `_Config::cloud`
36
+ access key that the S3 helpers use. Reason: keep the blast radius small — leaking that one
37
+ shared key must not also expose the SAML SP private keys. Grant the reading role its own
38
+ Secrets Manager permission instead.
39
+ 3. `$awsRegion` defaults to `null`; pass the secret's region explicitly (SAML passes
40
+ `'us-east-1'`).
41
+ 4. Returns the secret's JSON decoded to an **assoc array**. For the SSL secrets the keys are
42
+ `certificate`, `privateKey`, `certificateChain` (optional), `passphrase` (optional).
43
+ 5. **Caches per PHP process** (function-static) — repeated reads of the same secret in one
44
+ request hit the cache, not the API.
45
+ 6. **Never throws.** Any failure (missing secret, no permission, bad JSON) logs and returns
46
+ `null`, so a caller can skip one unusable secret and keep going rather than 500 the request.
47
+
48
+ ## Client variations
49
+
50
+ None — shared core helper.
51
+
52
+ ## Gotchas / known issues
53
+
54
+ - **Returns `null` on failure, does not throw.** A caller MUST null-check the result; a silent
55
+ `null` means "secret unavailable", not "empty secret".
56
+ - **The role needs its own Secrets Manager grant.** Because it does not use the `_Config::cloud`
57
+ key, an instance/role with no `secretsmanager:GetSecretValue` gets `null` for every secret.
58
+ The `saml-production` EB instance role needed an inline policy added (see the SAML architecture
59
+ Deployment notes).
60
+ - **A committed AWS access key + secret were found in a code comment in `Cloud.php` `initialize()`
61
+ and removed (2026-09-15).** Treat that key as leaked — it must be rotated separately from this
62
+ change. Never store credential VALUES in source or comments; reference secret names only.
63
+
64
+ ## Change history
65
+ - 2026-09-15 — Added `_Cloud::getSecret()` Secrets Manager reader: default-provider-chain auth
66
+ (not the shared `_Config::cloud` key), per-process cache, returns decoded assoc array or `null`
67
+ on any failure (never throws). First used by SAML `/acs` to source SP encryption certs. Also
68
+ removed a committed AWS access key/secret from a `Cloud.php` `initialize()` comment — treat as
69
+ leaked, rotate separately. (jcardinal)
70
+ </content>
71
+ </invoke>
@@ -6,7 +6,7 @@ project: SAML SSO Gateway
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-04
9
+ updated: 2026-09-15
10
10
  owners: ["rgirish", "jcardinal"]
11
11
  files:
12
12
  - saml/index.php
@@ -15,6 +15,7 @@ files:
15
15
  - saml/Config/production.ini
16
16
  - saml/.platform/hooks/prebuild/git.sh
17
17
  - saml/.ebextensions/git.php
18
+ - _underscore/Cloud.php
18
19
  related:
19
20
  - 2.0/apps/_underscore/architecture.md
20
21
  ---
@@ -49,7 +50,7 @@ Legacy `/?meta`, `/?acs`, `/?sls` query-style paths are rewritten automatically.
49
50
  2. **Version + time check** — RelayState JSON: `v` (currently `1`), `time`, `domain` UUID, optional `urlParameters`. Validated against `MAX_TIME_TO_LIVE` (300s).
50
51
  3. **Parse SAMLResponse** — base64-decoded, deserialized via LightSaml.
51
52
  4. **Status check** — proceeds only on `STATUS_SUCCESS`.
52
- 5. **Assertion decryption (dual-credential during cert overlap).** `/acs` builds two `X509Credential`s from the SP encryption keys in `_underscore/Assets/ssl/` (new + old) and calls `$reader->decryptMultiAssertion([$credentialNew, $credentialOld], $ctx)`. LightSaml's `decryptMulti()` tries each credential and only re-throws once all fail (fails closed), so an assertion encrypted to either the new or old SP key decrypts during a rotation overlap. See the SP cert-rotation workflow.
53
+ 5. **Assertion decryption (multi-cert, loaded from Secrets Manager at request time).** `/acs` loads the SP encryption cert+key pairs named in `ENCRYPTION_SECRET_NAMES` from AWS Secrets Manager (via `_Cloud::getSecret()`) at request time — **no key material lives on disk under `_underscore/Assets/ssl/`**. It builds an `X509Credential` per usable secret and calls `$reader->decryptMultiAssertion([...$credentials], $ctx)`. LightSaml's `decryptMulti()` tries each credential and only re-throws once all fail (fails closed), so an assertion encrypted to any current SP key decrypts, and rotation is just editing the secret list. See the SP cert-rotation workflow.
53
54
  6. **Resolve client + environment** — `_Model_Core_Domain` by RelayState `domain` UUID → `_Model_Core_Client` + `_Model_Core_Environment`. Dev on real AWS instance downgrades to beta.
54
55
  7. **Dynamic DB registration** — SQL joins `Clients`, `Databases`, `DatabaseHosts`, `Environments` for the environment slug. Calls `_Database::register()` + `connect()` for `DB_CLIENT`, `DB_CLIENT_LOGS`, `DB_CLIENT_ARCHIVE`.
55
56
  8. **Identity mapping** — `_Model_<ClientIdentifier>_ClientAuthentication::getAuthenticatedSsoUser($assertion)`. Wrapped in try/catch; exceptions sent to Sentry.
@@ -58,10 +59,11 @@ Legacy `/?meta`, `/?acs`, `/?sls` query-style paths are rewritten automatically.
58
59
 
59
60
  ### Failure responses
60
61
  `Invalid SAMLResponse (Code = 1–4)`, `SAML Authentication Failed`, `User Authentication Failed`.
62
+ Two codes cover the Secrets Manager cert path: **Code 5** — no usable SP credential (none of `ENCRYPTION_SECRET_NAMES` resolved to a valid cert+key); **Code 6** — a credential was built but every decrypt attempt failed (assertion not encrypted to any current SP key).
61
63
 
62
64
  ## /meta SP credential (signing vs encryption)
63
65
 
64
- `/meta` publishes TWO independent `KeyDescriptor`s: `use="signing"` and `use="encryption"`. They can carry **different** certs, which lets the SP encryption cert rotate without touching the signing cert. The private key that signs our **outbound** AuthnRequests lives in `_underscore` (`_Model_Core_ClientAuthentication::PATH_TO_PRIVATE_KEY`) and is **global — one key for all client IdPs**, so the signing cert cannot flip until every IdP has loaded the new metadata. Cert paths are set via class constants in `saml/Controller/Index.php` (`PATH_TO_SIGNING_CERTIFICATE`, `PATH_TO_ENCRYPTION_CERTIFICATE_NEW/OLD` + matching private-key constants — paths only, no key material). Full procedure: SP cert-rotation workflow.
66
+ `/meta` publishes TWO independent `KeyDescriptor`s: `use="signing"` and `use="encryption"`. They can carry **different** certs, which lets the SP encryption cert rotate without touching the signing cert. The private key that signs our **outbound** AuthnRequests lives in `_underscore` (`_Model_Core_ClientAuthentication::PATH_TO_PRIVATE_KEY`) and is **global — one key for all client IdPs**, so the signing cert cannot flip until every IdP has loaded the new metadata. The signing cert path is still a class constant in `saml/Controller/Index.php` (`PATH_TO_SIGNING_CERTIFICATE`). For encryption, only `PATH_TO_ENCRYPTION_CERTIFICATE_NEW` remains — `/meta` still reads it to publish the current encryption `KeyDescriptor`. The old on-disk constants `PATH_TO_ENCRYPTION_PRIVATE_KEY_NEW`, `PATH_TO_ENCRYPTION_PRIVATE_KEY_OLD`, and `PATH_TO_ENCRYPTION_CERTIFICATE_OLD` are **removed** — `/acs` no longer reads encryption key material from disk (it comes from Secrets Manager, see /acs step 5). Full procedure: SP cert-rotation workflow.
65
67
 
66
68
  ## Per-client SSO extension pattern
67
69
 
@@ -75,6 +77,7 @@ To onboard a new SSO client, add the class in `_underscore` — not in this repo
75
77
  - **`_underscore` pulled at deploy time** via `.ebextensions/git.php` + prebuild hook — clones branch `_production`. Deploy `saml` after `_underscore` merges to pick up framework changes.
76
78
  - **CodePipeline:** Source (GitHub `_production`) → ManualApproval → Deploy (EB).
77
79
  - **Composer deps** installed at postdeploy via `015_install_composer.sh`.
80
+ - **Secrets Manager read access.** The shared `ElasticBeanstalk-EC2-Instance-Profile` carries an inline `SecretsManagerReadAll` policy (`secretsmanager:GetSecretValue` + `DescribeSecret`, `Resource: "*"`) so `/acs` can read the SP encryption secrets at runtime. Developer chose account-wide `Resource: "*"` deliberately (shared profile across EB apps); verified effective access with the IAM policy simulator.
78
81
  - **Non-prod shared-ALB self-registration (2026-08-27).** Postdeploy hook
79
82
  `060_register_instance_to_shared_application_load_balancer.sh` + `ebs/register_instance_to_shared_application_load_balancer.php`
80
83
  register a non-production `saml` instance into the target group named exactly its EB env name;
@@ -95,6 +98,8 @@ To onboard a new SSO client, add the class in `_underscore` — not in this repo
95
98
  - **SAML signature verification is commented out — this is the repo's top security priority.** The IdP X509 cert verification block in the `/acs` flow is disabled, so the gateway accepts any well-formed, status-success `SAMLResponse` without proving it was signed by the expected IdP. The only remaining gate is the encrypted RelayState, which authenticates the *originating TOGa request* — not the *asserting IdP*. Threat model: an attacker who can craft or replay a `SAMLResponse` (or a malicious/compromised IdP) can forge an identity assertion for any user, since nothing binds the assertion to a trusted signing key. Re-enabling per-IdP X509 signature verification is the single highest-impact security fix for this repo.
96
99
  - **Plaintext secrets in source — move to AWS SSM Parameter Store.** `saml/Config/production.ini` stores credentials in plaintext, and a Sentry DSN is hardcoded in `saml/Controller/Index.php`. Both are checked into the repo. Migrate these to AWS SSM Parameter Store (SecureString) and load at runtime; the source tree should reference parameter names only, never literal credential values.
97
100
  - **`/meta` cert lines must be `trim()`-ed (CRLF).** The `.crt` files in `_underscore/Assets/ssl` are CRLF-encoded; the cert-format code does `explode("\n", ...)` and indents each line, so without a per-line `trim()` a stray `\r` ends up on every base64 line inside `<ds:X509Certificate>`. Trim each line in both the signing and encryption blocks.
101
+ - **SP encryption certs load from Secrets Manager at request time.** `/acs` holds no encryption key material on disk (`_underscore/Assets/ssl/` no longer carries the SP encryption key). A rotation is now an edit to the `ENCRYPTION_SECRET_NAMES` list + the secrets themselves, not a redeploy of on-disk `.crt`/`.key` files.
102
+ - **Committed AWS key must be rotated.** A hardcoded AWS key was removed from `_underscore/Cloud.php`; because it was committed to git history it is compromised — rotate it. Still open: the plaintext creds in `production.ini` and the hardcoded Sentry DSN (see next gotcha).
98
103
  - **SLS not implemented.** `/sls` falls through to the default banner.
99
104
  - **`set_exception_handler(null)` at top of `saml()`.** Unhandled exceptions output raw PHP errors. All exceptions from `getAuthenticatedSsoUser()` are caught and sent to Sentry.
100
105
 
@@ -103,3 +108,4 @@ To onboard a new SSO client, add the class in `_underscore` — not in this repo
103
108
  - 2026-06-25 — Sharpened signature-verification gotcha with explicit threat model and flagged it as the repo's top security priority; documented plaintext secrets in `production.ini` + hardcoded Sentry DSN in `Controller/Index.php` with SSM Parameter Store recommendation (jcardinal)
104
109
  - 2026-08-27 — Documented non-prod shared-ALB self-registration postdeploy hook (`060_...sh` + `ebs/register_instance_to_shared_application_load_balancer.php`); skips `production`, requires `aws/aws-sdk-php` + two IAM actions; cross-linked worker2 ALB auto-registration feature (jcardinal)
105
110
  - 2026-09-04 — `/acs` now dual-credential decrypt (`decryptMultiAssertion([new, old])`) for SP encryption-cert overlap; documented `/meta`'s two KeyDescriptors (signing vs encryption) and the global outbound-signing key; added the CRLF `/meta` cert-line `trim()` gotcha. (jcardinal)
111
+ - 2026-09-15 — `/acs` encryption certs now load from AWS Secrets Manager at request time via `_Cloud::getSecret()` (`ENCRYPTION_SECRET_NAMES`, no on-disk key material); added failure Codes 5 (no usable SP credential) and 6 (all decrypts failed); removed dead disk constants (`PATH_TO_ENCRYPTION_PRIVATE_KEY_NEW/_OLD`, `PATH_TO_ENCRYPTION_CERTIFICATE_OLD`), kept `PATH_TO_ENCRYPTION_CERTIFICATE_NEW` for `/meta`; documented the `SecretsManagerReadAll` inline policy on the shared EC2 instance profile; flagged the committed AWS key removed from `_underscore/Cloud.php` for rotation. (jcardinal)
@@ -6,16 +6,18 @@ project: SAML SSO Gateway
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-04
9
+ updated: 2026-09-15
10
10
  owners: ["jcardinal"]
11
11
  files:
12
- - _underscore/Assets/ssl/togahub_com.crt
13
- - _underscore/Assets/ssl/togahub_private_key.key
14
- - _underscore/Model/Core/ClientAuthentication.php
15
12
  - saml/Controller/Index.php
13
+ - _underscore/Cloud.php
14
+ - _underscore/Model/Core/ClientAuthentication.php
15
+ - _underscore/Assets/ssl/togahub_com.crt
16
16
  related:
17
17
  - 2.0/apps/saml/architecture.md
18
18
  - 2.0/apps/saml/workflows/onboarding-a-new-sso-client.md
19
+ - 2.0/apps/_underscore/features/cloud-secrets-manager.md
20
+ - 2.0/standards/ssl-certificate-trust-model.md
19
21
  ---
20
22
 
21
23
  ## Summary
@@ -78,11 +80,65 @@ uses and **rotates encryption first, signing later**:
78
80
  ### Remaining steps / follow-ups (not yet done)
79
81
  1. **Flip signing to the new cert** once every IdP has loaded the new metadata — advertise the
80
82
  new signing cert in `/meta` and switch (d) to the new key. Coordinate the date.
81
- 2. **Log which SP key decrypted** (new vs old, identifier only — never assertion data) so you can
82
- see which IdPs have cut over and when it is safe to drop the old key.
83
- 3. **Wrap the `/acs` decrypt in try/catch** and echo `Invalid SAMLResponse (Code = 5)` to match
84
- the existing coded failure responses.
85
- 4. **Dedupe the two `/meta` cert-format blocks** into a shared helper (non-blocking).
83
+ 2. **Log which SP key decrypted** (secret name / identifier only — never assertion data) so you
84
+ can see which IdPs have cut over and when it is safe to drop a cert from the list.
85
+ 3. **Dedupe the two `/meta` cert-format blocks** into a shared helper (non-blocking).
86
+
87
+ (Done 2026-09-15: the `/acs` decrypt is now wrapped and fails closed with coded responses —
88
+ `Invalid SAMLResponse (Code = 5)` when no credential builds, `Code = 6` when all decrypts fail.)
89
+
90
+ ## Multi-cert decryption from Secrets Manager (2026-09-15)
91
+
92
+ `/acs` no longer reads SP encryption keys from disk. It now sources **N** SP certs from AWS
93
+ Secrets Manager and decrypts against all of them at once, so a partner can move to a different
94
+ cert while other IdPs stay on the existing one — a superset of the earlier new/old overlap.
95
+
96
+ - **Two class constants in `saml/Controller/Index.php` drive it:**
97
+ - `ENCRYPTION_SECRET_NAMES` — an **ordered list of Secrets Manager secret NAMES** (not values).
98
+ - `ENCRYPTION_SECRET_REGION` — `'us-east-1'`.
99
+ - **How `/acs` builds credentials:** for each name it calls `_Cloud::getSecret($name, $region)`
100
+ (see the [_Cloud Secrets Manager helper](../../_underscore/features/cloud-secrets-manager.md)),
101
+ builds an `X509Credential`, then calls `decryptMultiAssertion($credentials, ...)`. An assertion
102
+ encrypted to ANY listed cert decrypts.
103
+ - **Fails closed with coded responses:** `Invalid SAMLResponse (Code = 5)` if no usable
104
+ credential can be built at all; `Code = 6` if credentials build but every decrypt fails. Each
105
+ per-secret build is wrapped in try/catch — a bad/unreadable secret is skipped and logged, so
106
+ one broken secret does not 500 every login.
107
+ - **Passphrase-aware, format-tolerant key loading:** PEM stored with escaped `\n` / `\r\n` is
108
+ normalized to real newlines before loading. The secret's `passphrase` is passed to
109
+ `KeyHelper::createPrivateKey` **only** when the private-key PEM header is the
110
+ `ENCRYPTED PRIVATE KEY` variant (ACM public-cert exports are always encrypted); a
111
+ plaintext key loads with no passphrase. A large "how this works" comment sits above
112
+ `ENCRYPTION_SECRET_NAMES` in the controller.
113
+ - **To add or retire a cert: edit `ENCRYPTION_SECRET_NAMES` only** (and add/update the secret in
114
+ Secrets Manager) — no file swap on disk, no `_underscore` cert-file change. This replaces the
115
+ old "swap the `.crt`/`.key` files + redeploy" step for the encryption credential.
116
+ - **Dead constants removed** from `saml/Controller/Index.php`:
117
+ `PATH_TO_ENCRYPTION_PRIVATE_KEY_NEW`, `PATH_TO_ENCRYPTION_PRIVATE_KEY_OLD`,
118
+ `PATH_TO_ENCRYPTION_CERTIFICATE_OLD`. `PATH_TO_ENCRYPTION_CERTIFICATE_NEW` **stays** — `/meta`
119
+ still reads it from disk to publish the encryption `KeyDescriptor`.
120
+ - **The `saml-production` EB instance role needed a Secrets Manager grant** — see the SAML
121
+ architecture Deployment notes.
122
+
123
+ ### Secret names in `ENCRYPTION_SECRET_NAMES` (WHERE only — never the values)
124
+
125
+ AWS Secrets Manager, account **654654170868**, region **us-east-1**. Each secret is JSON with
126
+ keys `certificate`, `privateKey` (plaintext OR encrypted), `certificateChain` (optional),
127
+ `passphrase` (optional; present only for an encrypted key):
128
+
129
+ - `toga/ssl/togahub_com-selfsigned-2036` — the self-signed 10-year cert; kept for clients OK
130
+ with self-signed.
131
+ - `toga/ssl/togahub_com-casigned-2027-03-31` — publicly-trusted (ACM exportable) cert.
132
+ - `toga/ssl/togahub_com-casigned-2026-09-16` — publicly-trusted (ACM exportable) cert.
133
+
134
+ ### Why a public cert was added (Prudential)
135
+
136
+ Prudential's IdP **refuses a self-signed cert, pins our exact leaf, and cannot auto-refresh our
137
+ metadata by URL** — that combination forces a publicly-trusted cert. The correct source is an
138
+ **ACM exportable public cert** (gives cert + private key), not ACM Private CA. Prudential moves
139
+ to the public cert while other IdPs stay on the self-signed one; all coexist in the `/acs`
140
+ multi-cert array. For the full trust-model rationale see the
141
+ [SSL certificate trust-model strategy](../../standards/ssl-certificate-trust-model.md).
86
142
 
87
143
  ## Current credential (as of 2026-08-27, staged — cutover pending)
88
144
 
@@ -149,6 +205,16 @@ every pinning partner to reinstall on each renewal. The dedicated self-signed 10
149
205
  **decouples the SP credential from TLS renewals** — that is the whole point of the change.
150
206
 
151
207
  ## Change history
208
+ - 2026-09-15 — `/acs` now sources SP encryption certs from **AWS Secrets Manager** (via
209
+ `_Cloud::getSecret`), not disk: two new constants `ENCRYPTION_SECRET_NAMES` (ordered secret
210
+ NAMES) + `ENCRYPTION_SECRET_REGION`, N-cert `decryptMultiAssertion`, fails closed with
211
+ `Code = 5`/`Code = 6`, per-secret try/catch, and passphrase-aware/format-tolerant key loading
212
+ (passphrase only for `ENCRYPTED PRIVATE KEY`). To add/retire a cert, edit the list only.
213
+ Removed dead disk-path constants (`PATH_TO_ENCRYPTION_PRIVATE_KEY_NEW/_OLD`,
214
+ `PATH_TO_ENCRYPTION_CERTIFICATE_OLD`); `PATH_TO_ENCRYPTION_CERTIFICATE_NEW` stays for `/meta`.
215
+ Added a publicly-trusted (ACM exportable) cert for Prudential, which refuses self-signed + pins
216
+ our leaf + cannot auto-pull metadata; self-signed and public certs now coexist in the array.
217
+ (jcardinal)
152
218
  - 2026-08-27 — Created while replacing the SAML SP credential. Documented the SP cert's dual use
153
219
  (signing AuthnRequests / decrypting assertions), the new self-signed 10-year cert identity and
154
220
  its AWS Secrets Manager location, the pre-cutover TLS-usage safety check, and the
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-27
8
+ updated: 2026-09-15
9
9
  owners: [jcardinal]
10
10
  files: []
11
11
  related:
@@ -24,6 +24,8 @@ infra). It was established while replacing the 2.0 SAML SP credential — see th
24
24
  **Critical rule:** if a partner installs/pins OUR specific cert, use a **self-signed, long-lived**
25
25
  cert. If AWS terminates the TLS for browsers/generic clients, use **ACM public, auto-renewing**.
26
26
  Never reuse a public-CA TLS cert as a pinned bilateral credential.
27
+ **Exception:** a bilateral partner that **refuses a self-signed cert AND cannot auto-refresh
28
+ metadata** gets an **ACM *exportable* public cert** (see case 1's carve-out) — not ACM Private CA.
27
29
 
28
30
  ## The decision
29
31
 
@@ -33,6 +35,13 @@ Family), PunchOut endpoints a buyer pins. Use a **self-signed** cert with a mult
33
35
  (we generate 10-year). Distribute the public cert to the partner; import cert + key into the
34
36
  service. Because trust is by pinning, no public CA is involved and validity can be long.
35
37
 
38
+ **Carve-out — a partner that refuses self-signed AND can't auto-refresh metadata.** Some IdPs
39
+ reject a self-signed SP cert but also won't re-pull our metadata on a schedule, so a short public
40
+ cert would silently break them at every renewal. For these, use an **ACM *exportable* public
41
+ cert** (publicly-trusted so they accept it; exportable so we can install cert+key into the SP).
42
+ Do **not** reach for ACM Private CA (~$400/mo) to solve this — the exportable public cert covers
43
+ it. You still redeploy on each ~198-day renewal (case 3's tradeoff), but the partner is happy.
44
+
36
45
  ### 2. Standard HTTPS, AWS-terminated → ACM public, non-exportable, auto-renewing
37
46
  Browsers / generic clients reach the endpoint through an ALB, CloudFront, or API Gateway that AWS
38
47
  terminates. Use an **ACM public certificate** (non-exportable, auto-renewing). Distribute nothing;
@@ -40,15 +49,28 @@ renewal is invisible.
40
49
 
41
50
  ### 3. Standard HTTPS, NOT AWS-terminated → ACM exportable, or move behind an ALB
42
51
  A server terminates its own TLS (e.g. the legacy Vision box). Options: an **ACM *exportable*
43
- public cert** (a real 2025 AWS feature) — but still ~13-month validity, so you must redeploy on
52
+ public cert** (a real 2025 AWS feature) — but only ~198-day validity today, so you must redeploy on
44
53
  every renewal — OR move the endpoint behind an ALB to get free auto-renew (option 2).
45
54
 
55
+ ### Hybrid end-state (bilateral endpoints with mixed partners)
56
+ A single bilateral endpoint can end up serving **both** kinds of partner at once: some accept and
57
+ pin our self-signed long-lived cert (case 1), others refuse self-signed and need an exportable
58
+ public cert (the case-1 carve-out). These coexist. The SAML SP `/acs` handler already supports
59
+ this: it holds **multiple** SP encryption cert+key pairs (`ENCRYPTION_SECRET_NAMES` in AWS Secrets
60
+ Manager) and tries each on decrypt, so a self-signed keeper and a public-cert refuser can be live
61
+ side by side — you add/rotate one partner's cert without disturbing the other. See the
62
+ [saml architecture](../apps/saml/architecture.md) `/acs` flow.
63
+
46
64
  ## Why long-lived ⇒ self-signed
47
65
 
48
- **Every publicly-trusted cert, exportable or not, is capped at ~398 days** by the CA/Browser
49
- Forum, dropping toward ~47 days by 2029. Only **self-signed / private-CA** certs can be
50
- multi-year. So "long-lived AND distributable" necessarily means self-signed (or AWS Private CA at
51
- ~$400/mo — rejected here as overkill).
66
+ **Every publicly-trusted cert, exportable or not, is short-lived and shrinking** under the
67
+ CA/Browser Forum schedule: ~198 days today (what ACM issues now), stepping down to a **mandatory
68
+ ~47-day floor by 2029**. The often-quoted "10 days" is not the cert lifetime — it is the
69
+ **domain-validation reuse window** (how long a prior validation can be reused before you must
70
+ re-validate); the ~6-day Let's Encrypt cert is an **optional** short-lived offering, not the CA/B
71
+ requirement. Only **self-signed / private-CA** certs can be multi-year. So "long-lived AND
72
+ distributable" necessarily means self-signed (or AWS Private CA at ~$400/mo — rejected here as
73
+ overkill).
52
74
 
53
75
  The pain of "shrinking public-cert validity" is really a **distribution problem**: automate
54
76
  distribution via ACM and it disappears; a partner who pins the leaf breaks on every rotation.
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 83 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 84 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 69 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -11,11 +11,12 @@ apps:
11
11
  - library
12
12
  - toga2-commerce
13
13
  - toga25-supply
14
+ - saml
14
15
  project: _Underscore
15
16
  client: prudential
16
17
  type: profile
17
18
  status: active
18
- updated: 2026-09-11
19
+ updated: 2026-09-15
19
20
  owners: ["jcardinal", "rgirish", "bala", "mhammontree"]
20
21
  files: []
21
22
  related:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.812",
3
+ "version": "1.0.813",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",