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.
124 lines
4.0 KiB
Go
124 lines
4.0 KiB
Go
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, "&")
|
|
}
|