| 1 | package connect |
| 2 | |
| 3 | import ( |
| 4 | "encoding/base64" |
| 5 | "errors" |
| 6 | "net" |
| 7 | "net/url" |
| 8 | "os" |
| 9 | "strings" |
| 10 | |
| 11 | "amadan.net/rastrillo/rastrillo/assertion" |
| 12 | json "github.com/go-json-experiment/json" |
| 13 | ) |
| 14 | |
| 15 | // Credentials explicitly enables Home authentication. The public key manifest |
| 16 | // is StdBase64-encoded JSON mapping key IDs to StdBase64 P-256 public keys. |
| 17 | // DEKBase64 is a dedicated handoff/storage key, never the document content key. |
| 18 | // Directory is the optional hub directory origin (HOME_DIRECTORY_ORIGIN), which |
| 19 | // an app's switcher and "All apps" link to. It enables nothing on its own, and |
| 20 | // it is configured rather than derived from Issuer: stripping "id." from the |
| 21 | // credential origin is not a derivation anyone has promised. |
| 22 | type Credentials struct { |
| 23 | Issuer string |
| 24 | ConsumerID string |
| 25 | KeysBase64 string |
| 26 | DEKBase64 string |
| 27 | Directory string |
| 28 | } |
| 29 | |
| 30 | type DecodedCredentials struct { |
| 31 | Issuer string |
| 32 | ConsumerID string |
| 33 | Keys map[string][]byte |
| 34 | DEK []byte |
| 35 | Directory string |
| 36 | } |
| 37 | |
| 38 | var ErrConfig = errors.New("home connect: invalid configuration") |
| 39 | |
| 40 | // A nil config preserves standalone authentication; an incomplete config |
| 41 | // cannot silently fall back to it. Errors never include supplied values. |
| 42 | func DecodeCredentials(cfg *Credentials, origin string) (*DecodedCredentials, error) { |
| 43 | if cfg == nil { |
| 44 | return nil, nil |
| 45 | } |
| 46 | if !homeConsumerID(cfg.ConsumerID) { |
| 47 | return nil, ErrConfig |
| 48 | } |
| 49 | dek, ok := homeConfigBase64(cfg.DEKBase64, 32) |
| 50 | if !ok { |
| 51 | return nil, ErrConfig |
| 52 | } |
| 53 | if len(dek) != 32 { |
| 54 | return nil, ErrConfig |
| 55 | } |
| 56 | manifest, ok := homeConfigBase64(cfg.KeysBase64, 4096) |
| 57 | if !ok { |
| 58 | return nil, ErrConfig |
| 59 | } |
| 60 | var encoded map[string]string |
| 61 | // This decoder rejects duplicate names, invalid UTF-8 and trailing values. |
| 62 | // A map of strings also rejects wrapper objects, arrays and nested key data. |
| 63 | if json.Unmarshal(manifest, &encoded) != nil || len(encoded) == 0 || len(encoded) > 8 { |
| 64 | return nil, ErrConfig |
| 65 | } |
| 66 | keys := make(map[string][]byte, len(encoded)) |
| 67 | for id, encodedKey := range encoded { |
| 68 | key, ok := homeConfigBase64(encodedKey, 65) |
| 69 | if !ok { |
| 70 | return nil, ErrConfig |
| 71 | } |
| 72 | keys[id] = key |
| 73 | } |
| 74 | if _, err := assertion.NewVerifier(cfg.Issuer, origin, keys); err != nil { |
| 75 | return nil, ErrConfig |
| 76 | } |
| 77 | if cfg.Directory != "" && (!bareOrigin(cfg.Directory) || cfg.Directory == cfg.Issuer) { |
| 78 | return nil, ErrConfig |
| 79 | } |
| 80 | return &DecodedCredentials{Issuer: cfg.Issuer, ConsumerID: cfg.ConsumerID, Keys: keys, DEK: dek, Directory: cfg.Directory}, nil |
| 81 | } |
| 82 | |
| 83 | // bareOrigin is Home's originHost rule (internal/home/origins.go), which |
| 84 | // this module cannot import: https, a lower-case DNS host of at least two |
| 85 | // labels, no IP literal, no port, no userinfo, path, query or fragment. |
| 86 | func bareOrigin(origin string) bool { |
| 87 | u, err := url.Parse(origin) |
| 88 | if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || origin != "https://"+u.Host || u.Host != strings.ToLower(u.Host) || net.ParseIP(u.Host) != nil || len(u.Host) > 253 { |
| 89 | return false |
| 90 | } |
| 91 | labels := strings.Split(u.Host, ".") |
| 92 | if len(labels) < 2 { |
| 93 | return false |
| 94 | } |
| 95 | for _, label := range labels { |
| 96 | if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { |
| 97 | return false |
| 98 | } |
| 99 | for _, c := range label { |
| 100 | if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { |
| 101 | return false |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | return true |
| 106 | } |
| 107 | |
| 108 | func homeConsumerID(v string) bool { |
| 109 | if len(v) == 0 || len(v) > 64 { |
| 110 | return false |
| 111 | } |
| 112 | for _, b := range []byte(v) { |
| 113 | if !(b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' || b == '_' || b == '-') { |
| 114 | return false |
| 115 | } |
| 116 | } |
| 117 | return true |
| 118 | } |
| 119 | |
| 120 | func homeConfigBase64(encoded string, limit int) ([]byte, bool) { |
| 121 | if encoded == "" || len(encoded) > base64.StdEncoding.EncodedLen(limit) { |
| 122 | return nil, false |
| 123 | } |
| 124 | decoded, err := base64.StdEncoding.Strict().DecodeString(encoded) |
| 125 | if err != nil || len(decoded) > limit || base64.StdEncoding.EncodeToString(decoded) != encoded { |
| 126 | return nil, false |
| 127 | } |
| 128 | return decoded, true |
| 129 | } |
| 130 | |
| 131 | // LoadEnv returns nil only when every setting is absent or empty. A partial |
| 132 | // configuration refuses boot rather than falling back to local sign-in. |
| 133 | func LoadEnv(prefix, origin string) (*DecodedCredentials, error) { |
| 134 | return DecodeCredentials(CredentialsFromEnv(prefix), origin) |
| 135 | } |
| 136 | |
| 137 | // The directory origin is read from HOME_DIRECTORY_ORIGIN, unprefixed: it is |
| 138 | // the same name, and the same value, Home itself reads. It is optional, so it |
| 139 | // does not count toward "every setting is absent". |
| 140 | func CredentialsFromEnv(prefix string) *Credentials { |
| 141 | c := Credentials{Issuer: os.Getenv(prefix + "_HOME_ISSUER"), ConsumerID: os.Getenv(prefix + "_HOME_CONSUMER_ID"), KeysBase64: os.Getenv(prefix + "_HOME_KEYS_BASE64"), DEKBase64: os.Getenv(prefix + "_HOME_DEK_BASE64")} |
| 142 | if c.Issuer == "" && c.ConsumerID == "" && c.KeysBase64 == "" && c.DEKBase64 == "" { |
| 143 | return nil |
| 144 | } |
| 145 | c.Directory = os.Getenv("HOME_DIRECTORY_ORIGIN") |
| 146 | return &c |
| 147 | } |
| 148 | |