Skip to content

CXP-963: accept literal \n escapes in GitHub App private key PEM - #185

Merged
luisina-santos merged 7 commits into
mainfrom
luisinasantos/fix-github-app-privatekey-config-form
Aug 31, 2026
Merged

CXP-963: accept literal \n escapes in GitHub App private key PEM#185
luisina-santos merged 7 commits into
mainfrom
luisinasantos/fix-github-app-privatekey-config-form

Conversation

@luisina-santos

Copy link
Copy Markdown
Contributor

Summary

  • The app-privatekey field renders as a single-line input in the C1 config form, which cannot hold real newlines, so a PEM pasted there always failed with invalid private key PEM format before any request to GitHub.
  • loadPrivateKeyFromString now unescapes literal \n sequences before calling pem.Decode, mirroring baton-okta's parseRSAPrivateKey (pkg/oktaauth/oktaauth.go), so a PEM with \n in place of real newlines — the shape a single-line form field can actually submit — now parses.
  • CLI/env-supplied keys (real newlines) continue to work unchanged.
  • Updated the field description to document the accepted \n-escaped shape, and regenerated config_schema.json.

Fixes CXP-963.

Test plan

  • go build ./cmd/baton-*
  • go test ./... (66 passed)
  • Added TestLoadPrivateKeyFromString: parses a PEM with real newlines, parses the same PEM with \n escapes substituted (the config-form shape), errors on garbage input
  • Existing TestAppPrivateKeyPEM still passes unchanged

🤖 Generated with Claude Code

@linear-code

linear-code Bot commented Aug 31, 2026

Copy link
Copy Markdown

CXP-963

Comment thread pkg/connector/connector.go Outdated
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: CXP-963: accept literal \n escapes in GitHub App private key PEM

Blocking Issues: 0 | Suggestions: 1 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base f8243adf41e4.
Review mode: full
View review run

Review Summary

Scanned the full PR diff (pkg/connector/connector.go, pkg/config/config.go, the new TestLoadPrivateKeyFromString table, config_schema.json, README.md, docs/connector.mdx) for security and correctness; no dependency manifests changed. Prior feedback is addressed: the earlier \r\n/\r finding is now handled by the escapedLineBreaks replacer, whose rule order (\r\n before \n before \r) correctly collapses a CRLF-escaped pair into one newline, and the 2deac915 revert deliberately scopes out the leading-space / newline-stripped paste shapes raised in review — a documented product decision, not a defect. The unescaping is safe against real-newline inputs from --app-privatekey-path because a valid PEM body and armor can never contain a backslash, the field description matches the regenerated config_schema.json and README.md byte-for-byte, and docs/connector.mdx documents the new paste path (D1–D4 satisfied). Only a test-coverage nit remains.

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connector/app_privatekey_test.go:55-62 - all six shapes use PKCS#1 armor, leaving loadPrivateKeyFromString's PKCS#8 branch and its *rsa.PrivateKey type assertion uncovered.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connector/app_privatekey_test.go`:
- Around line 55-62: The `shapes` map in `TestLoadPrivateKeyFromString` derives every
  case from `realNewlines`, which is a PKCS#1 `RSA PRIVATE KEY` block. As a result the
  PKCS#8 branch of `loadPrivateKeyFromString` in `pkg/connector/connector.go` (the
  `block.Type == "PRIVATE KEY"` path, its `x509.ParsePKCS8PrivateKey` call, and the
  `key.(*rsa.PrivateKey)` two-value type assertion plus its "not an RSA private key"
  error) is never exercised, even though the fixtures in the neighbouring
  `TestAppPrivateKeyPEM` use `PRIVATE KEY` armor. Add a second base PEM built with
  `x509.MarshalPKCS8PrivateKey(key)` wrapped in `pem.Block{Type: "PRIVATE KEY"}` and run
  the same escaping shapes against it (for example by looping the shape table over both
  base PEMs), so both armor types the function accepts are covered. Optionally add a
  negative case feeding a PKCS#8 block holding a non-RSA key (e.g. an Ed25519 key) to
  assert the "not an RSA private key" error. This is a coverage nit only — GitHub App
  keys are issued as PKCS#1, so there is no known runtime bug here.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@luisina-santos
luisina-santos force-pushed the luisinasantos/fix-github-app-privatekey-config-form branch from 2ad0352 to c58c68a Compare August 31, 2026 15:19
@luisina-santos luisina-santos changed the title fix(config): accept literal \n escapes in GitHub App private key PEM CXP-963: accept literal \n escapes in GitHub App private key PEM Aug 31, 2026
Comment thread pkg/connector/connector.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

luisina-santos added a commit that referenced this pull request Aug 31, 2026
A PEM escaped from a CRLF-origin (Windows) file into a single line
produces \r\n rather than \n between lines. loadPrivateKeyFromString
only unescaped \n, so the leftover \r broke pem.Decode. Use a
strings.Replacer that maps \r\n and \n to a real newline and drops any
remaining \r.

Addresses a bot review suggestion on PR #185.
Comment thread pkg/connector/connector.go Outdated
}

func loadPrivateKeyFromString(p string) (*rsa.PrivateKey, error) {
p = strings.ReplaceAll(p, `\n`, "\n")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Connector] Two more paste shapes still fail, including the one the PR body targets

Measured with this function copied verbatim against a real 2048-bit RSA key. Beyond the \r\n case already raised above, these still return invalid private key PEM format:

shape result
\n-escaped, as the docs instruct passes
\n-escaped, one trailing space passes
\n-escaped, one leading space fails
newlines stripped, all one line fails
newlines replaced by spaces fails

A trailing space is harmless but a leading one is fatal, because pem.Decode only accepts the BEGIN armor at offset 0 or immediately after a newline. The last two are what a browser produces when a multi-line PEM is pasted straight into a single-line <input> — which is the case the PR body describes as fixed ("a pasted PEM always failed ... now parses").

Folding these into the \r handling already suggested, one normalization covers all of them:

var pemArmorHead = regexp.MustCompile(`(-----BEGIN [A-Z0-9 ]+-----)[ \t]*`)
var pemArmorTail = regexp.MustCompile(`[ \t]*(-----END [A-Z0-9 ]+-----)`)

func loadPrivateKeyFromString(p string) (*rsa.PrivateKey, error) {
	// The C1 config form submits this field on a single line, so the PEM arrives with
	// its line breaks escaped, flattened to spaces, or stripped out entirely.
	p = strings.NewReplacer(`\r\n`, "\n", `\n`, "\n", `\r`, "\n").Replace(p)
	p = strings.TrimSpace(p)
	p = pemArmorHead.ReplaceAllString(p, "$1\n")
	p = pemArmorTail.ReplaceAllString(p, "\n$1")

	block, _ := pem.Decode([]byte(p))

This passes all eight shapes with no regression on real newlines, real CRLF, or the file-upload path — app-privatekey-path is a FileUploadField carrying real PEM bytes, and since neither base64 nor the armor can contain a backslash the replacer is a no-op there.

One consequence worth deciding rather than inheriting: with this in place, the instruction at docs/connector.mdx:297 to replace every line break with a literal \n becomes optional instead of required, since pasting the PEM as-is would work.

Non-blocking.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-measured against 38a48296, which lands the \r handling. Three shapes still fail:

shape result
real newlines · real CRLF · \n-escaped pass
\r\n-escaped pass — fixed by this commit
\n-escaped, one leading space fails
newlines stripped, all one line fails
newlines replaced by spaces fails

A trailing space is harmless; a leading one is fatal, because pem.Decode only accepts the BEGIN armor at offset 0 or immediately after a newline. The last two are what a browser produces when a multi-line PEM is pasted into a single-line <input> — the case the PR body describes as fixed.

Extending what you already have covers all three:

var pemArmorHead = regexp.MustCompile(`(-----BEGIN [A-Z0-9 ]+-----)[ \t]*`)
var pemArmorTail = regexp.MustCompile(`[ \t]*(-----END [A-Z0-9 ]+-----)`)

p = escapedLineBreaks.Replace(p)
p = strings.TrimSpace(p)
p = pemArmorHead.ReplaceAllString(p, "$1\n")
p = pemArmorTail.ReplaceAllString(p, "\n$1")

Measured: that passes every shape above, with no regression on real newlines, real CRLF, or the file-upload path — app-privatekey-path carries real PEM bytes, and neither base64 nor the armor can contain a backslash, so the replacer is a no-op there.

One consequence worth deciding rather than inheriting: with the armor handling in, the instruction at docs/connector.mdx:297 to replace every line break with a literal \n becomes optional instead of required, since pasting the PEM as-is would work.

Still non-blocking — fine to ship the \r fix now and take this as a follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Landed the full normalization from your later re-measured suggestion in aaf07ab: escapedLineBreaks + TrimSpace + pemArmorHead/pemArmorTail regexes. All 9 shapes (including leading-space and newlines-stripped/space-joined) now covered by a table-driven test in app_privatekey_test.go.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Scoped this back down — see 2deac91. Kept the \r\n/\r unescaping (same documented -escaping contract, just covering the CRLF/CR-origin case), but dropped the armor-repositioning regex (TrimSpace + forcing BEGIN/END onto their own line). That piece was recovering from not following the documented escaping instructions at all — unverified browser paste behavior rather than a line-break-convention gap — so leaving it as a follow-up if it turns out to matter in practice, per your note that it's fine to defer. Reopening this thread since the code no longer matches what I said was fixed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the paste behavior, since that was the open question.

Chromium 150, pasting a multi-line PEM into the live app-privatekey field with Ctrl+V from the system clipboard: the value arrives on one line with each newline replaced by a space. Character count is preserved (155 in, 155 out), and the joins read ----- AAAAB3Nza… after the BEGIN armor and …1111 -----END before the END. So the space-joined shape is what an operator gets from copy-pasting the .pem — not from ignoring the escaping instructions.

Correcting one thing in my earlier comment: "newlines stripped, all one line" is what a programmatic value set produces (the input's value-sanitization path, 155 → 152 chars), not a real paste. The space-joined shape is the real one.

Why the armor regexes fixed it: Go's base64 decoder tolerates spaces inside the body, so the only thing the pasted shape is actually missing is a newline after BEGIN and before END. TrimSpace is independent of that — a single leading space is an ordinary paste artifact and is fatal on its own, because pem.Decode only accepts the BEGIN armor at offset 0 or immediately after a newline.

Scope note: measured on Chromium only; other engines may sanitize pasted line breaks differently, and I have not checked them.

Still your call whether this clears the bar for this PR or stays a follow-up — the deferral was reasonable on the evidence available when you made it.

Comment thread pkg/connector/connector.go Outdated
luisina-santos added a commit that referenced this pull request Aug 31, 2026
Beyond \n and \r\n escapes, a browser can flatten a pasted multi-line
PEM by stripping newlines entirely or replacing them with spaces, and
a leading space before the BEGIN armor breaks pem.Decode (it only
recognizes the armor at offset 0 or right after a newline). Normalize
all escaped line-break forms to a real newline, trim surrounding
whitespace, and force the BEGIN/END armor onto its own line before
decoding. Table-driven test now covers all nine input shapes.

Addresses carolinaroncaglia's review comment on PR #185.
The app-privatekey field renders as a single-line input in the C1 config
form, which cannot hold real newlines, so a pasted PEM always failed to
parse and the connector could not be configured for GitHub App auth from
the form. Unescape literal \n sequences before decoding, mirroring
baton-okta's parseRSAPrivateKey.

Fixes CXP-963.
The C1 config form's GitHub App private key (PEM) field only accepts a
single line, so operators need to know to substitute literal \n for
line breaks. README.md's flag help text and the Cloud-hosted setup
steps in docs/connector.mdx now cover this alongside the existing
file-upload option.
…ine limit

golangci-lint's revive line-length-limit flagged the description added in
the previous commit. Reworded and wrapped it across two source lines;
regenerated config_schema.json and README.md to match.
A PEM escaped from a CRLF-origin (Windows) file into a single line
produces \r\n rather than \n between lines. loadPrivateKeyFromString
only unescaped \n, so the leftover \r broke pem.Decode. Use a
strings.Replacer that maps \r\n and \n to a real newline and drops any
remaining \r.

Addresses a bot review suggestion on PR #185.
Beyond \n and \r\n escapes, a browser can flatten a pasted multi-line
PEM by stripping newlines entirely or replacing them with spaces, and
a leading space before the BEGIN armor breaks pem.Decode (it only
recognizes the armor at offset 0 or right after a newline). Normalize
all escaped line-break forms to a real newline, trim surrounding
whitespace, and force the BEGIN/END armor onto its own line before
decoding. Table-driven test now covers all nine input shapes.

Addresses carolinaroncaglia's review comment on PR #185.
@luisina-santos
luisina-santos force-pushed the luisinasantos/fix-github-app-privatekey-config-form branch from aaf07ab to 62eb2f4 Compare August 31, 2026 17:52
realNewlines := string(pemBytes)

shapes := map[string]string{
"real newlines": realNewlines,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: This map entry is one space short of gofmt alignment — its value starts at column 46 while every other entry (including the longest key on line 61) aligns at column 47. gofmt -w fixes it; worth doing so the file doesn't trip a formatting gate in verify.

Suggested change
"real newlines": realNewlines,
"real newlines": realNewlines,

The armor-repositioning regexes (TrimSpace + BEGIN/END on their own
line) went beyond the ticket's scope: they recover from a user not
following the documented \n-escaping instructions at all (raw paste
mangled by the browser), rather than from a different line-break
convention. That's speculative browser behavior, not verified against
the actual C1 form, and adds meaningful parsing surface for an
unconfirmed edge case.

Keep the proportionate fix: unescape \r\n, \n, and \r to a real
newline, matching the documented "replace line breaks with \n"
contract across LF- and CRLF-origin keys.
Comment on lines +55 to +62
shapes := map[string]string{
"real newlines": realNewlines,
"real CRLF": strings.ReplaceAll(realNewlines, "\n", "\r\n"),
"backslash-n escaped, as the docs instruct": strings.ReplaceAll(realNewlines, "\n", `\n`),
"backslash-n escaped, trailing space": strings.ReplaceAll(realNewlines, "\n", `\n`) + " ",
"backslash-r-backslash-n escaped (CRLF file)": strings.ReplaceAll(realNewlines, "\n", `\r\n`),
"backslash-r escaped (CR-only file)": strings.ReplaceAll(realNewlines, "\n", `\r`),
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: every shape here is built from a PKCS#1 RSA PRIVATE KEY block, so loadPrivateKeyFromString's PKCS#8 branch — including the key.(*rsa.PrivateKey) type assertion and the "not an RSA private key" path — stays untested even though the existing TestAppPrivateKeyPEM fixtures use PRIVATE KEY armor. Adding one pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: x509.MarshalPKCS8PrivateKey(key)}) shape would cover both armor types the function accepts. Low confidence that this matters in practice (GitHub App keys are PKCS#1), so purely a coverage nit.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@luisina-santos
luisina-santos merged commit dd986de into main Aug 31, 2026
9 checks passed
@luisina-santos
luisina-santos deleted the luisinasantos/fix-github-app-privatekey-config-form branch August 31, 2026 20:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants