feat(llm): Amazon Bedrock provider over a standard-library SigV4 signer
Kind `bedrock` speaks the Converse API for chat roles and InvokeModel for Titan and Cohere embeddings. Requests are signed with AWS Signature Version 4 implemented with crypto/hmac and crypto/sha256 only; the signer is pinned by tests to signatures botocore produces for the same requests, with and without a session token, so drift fails the build. The credential is one secret reference, ACCESS_KEY_ID:SECRET_ACCESS_KEY [:SESSION_TOKEN], encrypted together like every other provider key. The region is read from the endpoint host, which must be a Bedrock runtime host over HTTPS unless private endpoints are explicitly allowed; other hosts are refused. Provider errors are scrubbed of the secret and token. Migration 0024 widens the provider kind constraint; existing rows are untouched and the down migration refuses while bedrock rows exist rather than dropping configuration. UI kind selects, docs/llm-providers.md, ADR-0010, ARCHITECTURE and the changelog are updated.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package providers
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AWS Signature Version 4 over the standard library. Only what Bedrock
|
||||
// needs: POST with a JSON body, a handful of headers, no query string. The
|
||||
// canonical URI is the request path URI-encoded a second time, as the
|
||||
// specification requires for every service except S3.
|
||||
|
||||
type awsCredentials struct {
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
SessionToken string
|
||||
}
|
||||
|
||||
// parseAWSCredentials reads "ACCESS_KEY_ID:SECRET_ACCESS_KEY[:SESSION_TOKEN]"
|
||||
// from a resolved secret reference. One reference keeps the whole
|
||||
// credential encrypted together; the secret never leaves this package.
|
||||
func parseAWSCredentials(s string) (awsCredentials, bool) {
|
||||
parts := strings.SplitN(strings.TrimSpace(s), ":", 3)
|
||||
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
|
||||
return awsCredentials{}, false
|
||||
}
|
||||
c := awsCredentials{AccessKeyID: parts[0], SecretAccessKey: parts[1]}
|
||||
if len(parts) == 3 {
|
||||
c.SessionToken = parts[2]
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
const (
|
||||
amzDateFormat = "20060102T150405Z"
|
||||
amzShortDate = "20060102"
|
||||
)
|
||||
|
||||
// signV4 adds X-Amz-Date, optionally X-Amz-Security-Token, and Authorization
|
||||
// to req for the given body. The Host header is signed; Content-Type is
|
||||
// signed when present.
|
||||
func signV4(req *http.Request, body []byte, creds awsCredentials, region, service string, now time.Time) {
|
||||
t := now.UTC()
|
||||
amzDate := t.Format(amzDateFormat)
|
||||
req.Header.Set("X-Amz-Date", amzDate)
|
||||
if creds.SessionToken != "" {
|
||||
req.Header.Set("X-Amz-Security-Token", creds.SessionToken)
|
||||
}
|
||||
host := req.Host
|
||||
if host == "" {
|
||||
host = req.URL.Host
|
||||
}
|
||||
headers := map[string]string{"host": host, "x-amz-date": amzDate}
|
||||
if ct := req.Header.Get("Content-Type"); ct != "" {
|
||||
headers["content-type"] = strings.TrimSpace(ct)
|
||||
}
|
||||
if creds.SessionToken != "" {
|
||||
headers["x-amz-security-token"] = creds.SessionToken
|
||||
}
|
||||
names := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var canonHeaders strings.Builder
|
||||
for _, k := range names {
|
||||
canonHeaders.WriteString(k + ":" + headers[k] + "\n")
|
||||
}
|
||||
signed := strings.Join(names, ";")
|
||||
payload := sha256.Sum256(body)
|
||||
path := req.URL.EscapedPath()
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
canonical := strings.Join([]string{req.Method, canonicalURI(path), canonicalQuery(req.URL.RawQuery), canonHeaders.String(), signed, hex.EncodeToString(payload[:])}, "\n")
|
||||
scope := t.Format(amzShortDate) + "/" + region + "/" + service + "/aws4_request"
|
||||
cr := sha256.Sum256([]byte(canonical))
|
||||
toSign := strings.Join([]string{"AWS4-HMAC-SHA256", amzDate, scope, hex.EncodeToString(cr[:])}, "\n")
|
||||
kDate := hmacSHA256([]byte("AWS4"+creds.SecretAccessKey), t.Format(amzShortDate))
|
||||
kRegion := hmacSHA256(kDate, region)
|
||||
kService := hmacSHA256(kRegion, service)
|
||||
kSigning := hmacSHA256(kService, "aws4_request")
|
||||
sig := hex.EncodeToString(hmacSHA256(kSigning, toSign))
|
||||
req.Header.Set("Authorization", "AWS4-HMAC-SHA256 Credential="+creds.AccessKeyID+"/"+scope+", SignedHeaders="+signed+", Signature="+sig)
|
||||
}
|
||||
|
||||
func hmacSHA256(key []byte, data string) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write([]byte(data))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// canonicalURI URI-encodes an already-encoded path once more, keeping '/'
|
||||
// and unreserved characters, which is what SigV4 requires outside S3.
|
||||
func canonicalURI(path string) string {
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(path); i++ {
|
||||
c := path[i]
|
||||
switch {
|
||||
case c == '/', (c >= 'A' && c <= 'Z'), (c >= 'a' && c <= 'z'), (c >= '0' && c <= '9'), c == '-', c == '_', c == '.', c == '~':
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
b.WriteString("%" + strings.ToUpper(hex.EncodeToString([]byte{c})))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// canonicalQuery sorts and encodes query parameters. Bedrock calls send none,
|
||||
// but the function keeps the signer complete.
|
||||
func canonicalQuery(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(raw, "&")
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
Reference in New Issue
Block a user