diff --git a/internals/api/credential.go b/internals/api/credential.go index de4ddce6..5b79671b 100644 --- a/internals/api/credential.go +++ b/internals/api/credential.go @@ -11,23 +11,28 @@ import ( "time" "github.com/secrethub/secrethub-go/internals/api/uuid" + "github.com/secrethub/secrethub-go/internals/crypto" ) // Errors var ( - ErrInvalidFingerprint = errAPI.Code("invalid_fingerprint").StatusError("fingerprint is invalid", http.StatusBadRequest) - ErrInvalidVerifier = errAPI.Code("invalid_verifier").StatusError("verifier is invalid", http.StatusBadRequest) - ErrInvalidCredentialType = errAPI.Code("invalid_credential_type").StatusError("credential type is invalid", http.StatusBadRequest) - ErrInvalidAWSEndpoint = errAPI.Code("invalid_aws_endpoint").StatusError("invalid AWS endpoint provided", http.StatusBadRequest) - ErrInvalidProof = errAPI.Code("invalid_proof").StatusError("invalid proof provided for credential", http.StatusUnauthorized) - ErrAWSAccountMismatch = errAPI.Code("aws_account_mismatch").StatusError("the AWS Account ID in the role ARN does not match the AWS Account ID of the AWS credentials used for authentication. Make sure you are using AWS credentials that correspond to the role you are trying to add.", http.StatusUnauthorized) - ErrAWSAuthFailed = errAPI.Code("aws_auth_failed").StatusError("authentication not accepted by AWS", http.StatusUnauthorized) - ErrAWSKMSKeyNotFound = errAPI.Code("aws_kms_key_not_found").StatusError("could not found the KMS key", http.StatusNotFound) - ErrInvalidRoleARN = errAPI.Code("invalid_role_arn").StatusError("provided role is not a valid ARN", http.StatusBadRequest) - ErrMissingMetadata = errAPI.Code("missing_metadata").StatusErrorPref("expecting %s metadata provided for credentials of type %s", http.StatusBadRequest) - ErrInvalidMetadataKey = errAPI.Code("invalid_metadata_key").StatusErrorPref("invalid metadata key %s for credential type %s", http.StatusBadRequest) - ErrUnknownMetadataKey = errAPI.Code("unknown_metadata_key").StatusErrorPref("unknown metadata key: %s", http.StatusBadRequest) - ErrRoleDoesNotMatch = errAPI.Code("role_does_not_match").StatusError("role in metadata does not match the verifier", http.StatusBadRequest) + ErrInvalidFingerprint = errAPI.Code("invalid_fingerprint").StatusError("fingerprint is invalid", http.StatusBadRequest) + ErrTooShortFingerprint = errAPI.Code("too_short_fingerprint").StatusErrorf("at least %d characters of the fingerprint must be entered", http.StatusBadRequest, ShortCredentialFingerprintMinimumLength) + ErrCredentialFingerprintNotUnique = errAPI.Code("fingerprint_not_unique").StatusErrorf("there are multiple credentials that start with the given fingerprint. Please use the full fingerprint", http.StatusConflict) + ErrInvalidVerifier = errAPI.Code("invalid_verifier").StatusError("verifier is invalid", http.StatusBadRequest) + ErrInvalidCredentialType = errAPI.Code("invalid_credential_type").StatusError("credential type is invalid", http.StatusBadRequest) + ErrInvalidCredentialDescription = errAPI.Code("invalid_credential_description").StatusError("credential description can be at most 32 characters long", http.StatusBadRequest) + ErrInvalidAWSEndpoint = errAPI.Code("invalid_aws_endpoint").StatusError("invalid AWS endpoint provided", http.StatusBadRequest) + ErrInvalidProof = errAPI.Code("invalid_proof").StatusError("invalid proof provided for credential", http.StatusUnauthorized) + ErrAWSAccountMismatch = errAPI.Code("aws_account_mismatch").StatusError("the AWS Account ID in the role ARN does not match the AWS Account ID of the AWS credentials used for authentication. Make sure you are using AWS credentials that correspond to the role you are trying to add.", http.StatusUnauthorized) + ErrAWSAuthFailed = errAPI.Code("aws_auth_failed").StatusError("authentication not accepted by AWS", http.StatusUnauthorized) + ErrAWSKMSKeyNotFound = errAPI.Code("aws_kms_key_not_found").StatusError("could not found the KMS key", http.StatusNotFound) + ErrInvalidRoleARN = errAPI.Code("invalid_role_arn").StatusError("provided role is not a valid ARN", http.StatusBadRequest) + ErrMissingMetadata = errAPI.Code("missing_metadata").StatusErrorPref("expecting %s metadata provided for credentials of type %s", http.StatusBadRequest) + ErrInvalidMetadataKey = errAPI.Code("invalid_metadata_key").StatusErrorPref("invalid metadata key %s for credential type %s", http.StatusBadRequest) + ErrUnknownMetadataKey = errAPI.Code("unknown_metadata_key").StatusErrorPref("unknown metadata key: %s", http.StatusBadRequest) + ErrRoleDoesNotMatch = errAPI.Code("role_does_not_match").StatusError("role in metadata does not match the verifier", http.StatusBadRequest) + ErrCannotDisableCurrentCredential = errAPI.Code("cannot_disable_current_credential").StatusError("cannot disable the credential that is currently used on this device", http.StatusConflict) ) // Credential metadata keys @@ -36,15 +41,20 @@ const ( CredentialMetadataAWSRole = "aws_role" ) +const ( + ShortCredentialFingerprintMinimumLength = 10 +) + // Credential is used to authenticate to the API and to encrypt the account key. type Credential struct { AccountID uuid.UUID `json:"account_id"` Type CredentialType `json:"type"` CreatedAt time.Time `json:"created_at"` Fingerprint string `json:"fingerprint"` - Name string `json:"name"` + Description string `json:"description"` Verifier []byte `json:"verifier"` Metadata map[string]string `json:"metadata,omitempty"` + Enabled bool `json:"enabled"` } // CredentialType is used to identify the type of algorithm that is used for a credential. @@ -52,8 +62,9 @@ type CredentialType string // Credential types const ( - CredentialTypeKey CredentialType = "key" - CredentialTypeAWS CredentialType = "aws" + CredentialTypeKey CredentialType = "key" + CredentialTypeAWS CredentialType = "aws" + CredentialTypeBackupCode CredentialType = "backup-code" ) const ( @@ -63,20 +74,26 @@ const ( // Validate validates whether the algorithm type is valid. func (a CredentialType) Validate() error { - if a == CredentialTypeKey || a == CredentialTypeAWS { - return nil + var credentialTypeList = map[CredentialType]struct{}{ + CredentialTypeKey: {}, + CredentialTypeAWS: {}, + CredentialTypeBackupCode: {}, + } + if _, ok := credentialTypeList[a]; !ok { + return ErrInvalidCredentialType } - return ErrInvalidCredentialType + return nil } // CreateCredentialRequest contains the fields to add a credential to an account. type CreateCredentialRequest struct { - Type CredentialType `json:"type"` - Fingerprint string `json:"fingerprint"` - Name string `json:"name,omitempty"` - Verifier []byte `json:"verifier"` - Proof interface{} `json:"proof"` - Metadata map[string]string `json:"metadata"` + Type CredentialType `json:"type"` + Fingerprint string `json:"fingerprint"` + Description *string `json:"name,omitempty"` + Verifier []byte `json:"verifier"` + Proof interface{} `json:"proof"` + Metadata map[string]string `json:"metadata"` + AccountKey *CreateAccountKeyRequest `json:"account_key,omitempty"` } // UnmarshalJSON converts a JSON representation into a CreateCredentialRequest with the correct Proof. @@ -102,6 +119,8 @@ func (req *CreateCredentialRequest) UnmarshalJSON(b []byte) error { dec.Proof = &CredentialProofAWS{} case CredentialTypeKey: dec.Proof = &CredentialProofKey{} + case CredentialTypeBackupCode: + dec.Proof = &CredentialProofBackupCode{} default: return ErrInvalidCredentialType } @@ -129,19 +148,38 @@ func (req *CreateCredentialRequest) Validate() error { return ErrMissingField("type") } + if req.Description != nil { + if err := ValidateCredentialDescription(*req.Description); err != nil { + return err + } + } + err := req.Type.Validate() if err != nil { return err } + if req.AccountKey != nil { + if err := req.AccountKey.Validate(); err != nil { + return err + } + } + + if req.Type == CredentialTypeBackupCode { + decoded, err := base64.StdEncoding.DecodeString(string(req.Verifier)) + if err != nil { + return ErrInvalidVerifier + } + if len(decoded) != sha256.Size { + return ErrInvalidVerifier + } + } + if req.Type == CredentialTypeAWS && req.Proof == nil { return ErrMissingField("proof") } - fingerprint, err := GetFingerprint(req.Type, req.Verifier) - if err != nil { - return err - } + fingerprint := GetFingerprint(req.Type, req.Verifier) if req.Fingerprint != fingerprint { return ErrInvalidFingerprint } @@ -192,8 +230,21 @@ func (p CredentialProofAWS) Validate() error { // CredentialProofKey is proof for when the credential type is RSA. type CredentialProofKey struct{} +// CredentialProofBackupCode is proof for when the credential type is backup key. +type CredentialProofBackupCode struct{} + +// UpdateCredentialRequest contains the fields of a credential that can be updated. +type UpdateCredentialRequest struct { + Enabled *bool `json:"enabled,omitempty"` +} + +// Validate whether the UpdateCredentialRequest is a valid request. +func (req *UpdateCredentialRequest) Validate() error { + return nil +} + // GetFingerprint returns the fingerprint of a credential. -func GetFingerprint(t CredentialType, verifier []byte) (string, error) { +func GetFingerprint(t CredentialType, verifier []byte) string { var toHash []byte if t == CredentialTypeKey { // Provide compatibility with traditional RSA credentials. @@ -203,10 +254,5 @@ func GetFingerprint(t CredentialType, verifier []byte) (string, error) { toHash = []byte(fmt.Sprintf("credential_type=%s;verifier=%s", t, encodedVerifier)) } - h := sha256.New() - _, err := h.Write(toHash) - if err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil)), nil + return hex.EncodeToString(crypto.SHA256(toHash)) } diff --git a/internals/api/credential_test.go b/internals/api/credential_test.go index 1739b0f3..e25f5504 100644 --- a/internals/api/credential_test.go +++ b/internals/api/credential_test.go @@ -7,20 +7,53 @@ import ( ) func TestCreateCredentialRequest_Validate(t *testing.T) { + description := "Personal laptop credential" + cases := map[string]struct { req CreateCredentialRequest err error }{ "success": { req: CreateCredentialRequest{ - Name: "Personal laptop credential", + Description: &description, + Type: CredentialTypeKey, + Fingerprint: "88c9eae68eb300b2971a2bec9e5a26ff4179fd661d6b7d861e4c6557b9aaee14", + Verifier: []byte("verifier"), + }, + err: nil, + }, + "success without description": { + req: CreateCredentialRequest{ Type: CredentialTypeKey, Fingerprint: "88c9eae68eb300b2971a2bec9e5a26ff4179fd661d6b7d861e4c6557b9aaee14", Verifier: []byte("verifier"), }, err: nil, }, - "success without name": { + "success including account key": { + req: CreateCredentialRequest{ + Description: &description, + Type: CredentialTypeKey, + Fingerprint: "88c9eae68eb300b2971a2bec9e5a26ff4179fd661d6b7d861e4c6557b9aaee14", + Verifier: []byte("verifier"), + AccountKey: &CreateAccountKeyRequest{ + EncryptedPrivateKey: NewEncryptedDataAESGCM([]byte("encrypted"), []byte("nonce"), 96, NewEncryptionKeyLocal(256)), + PublicKey: []byte("public-key"), + }, + }, + err: nil, + }, + "including invalid account key": { + req: CreateCredentialRequest{ + Description: &description, + Type: CredentialTypeKey, + Fingerprint: "88c9eae68eb300b2971a2bec9e5a26ff4179fd661d6b7d861e4c6557b9aaee14", + Verifier: []byte("verifier"), + AccountKey: &CreateAccountKeyRequest{}, + }, + err: ErrInvalidPublicKey, + }, + "success without Description": { req: CreateCredentialRequest{ Type: CredentialTypeKey, Fingerprint: "88c9eae68eb300b2971a2bec9e5a26ff4179fd661d6b7d861e4c6557b9aaee14", @@ -30,16 +63,16 @@ func TestCreateCredentialRequest_Validate(t *testing.T) { }, "no fingerprint": { req: CreateCredentialRequest{ - Type: CredentialTypeKey, - Name: "Personal laptop credential", - Verifier: []byte("verifier"), + Type: CredentialTypeKey, + Description: &description, + Verifier: []byte("verifier"), }, err: ErrMissingField("fingerprint"), }, "invalid fingerprint": { req: CreateCredentialRequest{ Type: CredentialTypeKey, - Name: "Personal laptop credential", + Description: &description, Fingerprint: "not-valid", Verifier: []byte("verifier"), }, @@ -48,7 +81,7 @@ func TestCreateCredentialRequest_Validate(t *testing.T) { "empty verifier": { req: CreateCredentialRequest{ Type: CredentialTypeKey, - Name: "Personal laptop credential", + Description: &description, Fingerprint: "fingerprint", Verifier: nil, }, @@ -56,7 +89,7 @@ func TestCreateCredentialRequest_Validate(t *testing.T) { }, "empty type": { req: CreateCredentialRequest{ - Name: "Personal laptop credential", + Description: &description, Fingerprint: "88c9eae68eb300b2971a2bec9e5a26ff4179fd661d6b7d861e4c6557b9aaee14", Verifier: []byte("verifier"), }, @@ -64,7 +97,7 @@ func TestCreateCredentialRequest_Validate(t *testing.T) { }, "invalid type": { req: CreateCredentialRequest{ - Name: "Personal laptop credential", + Description: &description, Fingerprint: "88c9eae68eb300b2971a2bec9e5a26ff4179fd661d6b7d861e4c6557b9aaee14", Verifier: []byte("verifier"), Type: CredentialType("invalid"), @@ -110,7 +143,7 @@ func TestCreateCredentialRequest_Validate(t *testing.T) { }, "extra metadata": { req: CreateCredentialRequest{ - Name: "Personal laptop credential", + Description: &description, Type: CredentialTypeKey, Fingerprint: "88c9eae68eb300b2971a2bec9e5a26ff4179fd661d6b7d861e4c6557b9aaee14", Verifier: []byte("verifier"), @@ -134,10 +167,52 @@ func TestCreateCredentialRequest_Validate(t *testing.T) { }, err: ErrUnknownMetadataKey("foo"), }, + "backup code success": { + req: CreateCredentialRequest{ + Type: CredentialTypeBackupCode, + Fingerprint: "69cf01c1e969b4430ca1b08ede7dab5f91a64a306e321f0348667446e1b3597e", + Verifier: []byte("DdAaVTKxoYgxzWY2UWrdl1xHOOv4ZUozra4Vm8WGxmU="), + Proof: &CredentialProofBackupCode{}, + Metadata: map[string]string{}, + }, + err: nil, + }, + "backup code too short verifier": { + req: CreateCredentialRequest{ + Type: CredentialTypeBackupCode, + Fingerprint: "69cf01c1e969b4430ca1b08ede7dab5f91a64a306e321f0348667446e1b3597e", + Verifier: []byte("DdAaVTKxoYgxzWY2UWrdl1OOv4ZUozra4Vm8WGxmU="), + Proof: &CredentialProofBackupCode{}, + Metadata: map[string]string{}, + }, + err: ErrInvalidVerifier, + }, + "backup code non base64 verifier": { + req: CreateCredentialRequest{ + Type: CredentialTypeBackupCode, + Fingerprint: "69cf01c1e969b4430ca1b08ede7dab5f91a64a306e321f0348667446e1b3597e", + Verifier: []byte("DdAaVTKxoYgxzWY2UWrdl1OOv4ZUozra4Vm8WGxm&="), + Proof: &CredentialProofBackupCode{}, + Metadata: map[string]string{}, + }, + err: ErrInvalidVerifier, + }, + "backup code with metadata": { + req: CreateCredentialRequest{ + Type: CredentialTypeBackupCode, + Fingerprint: "69cf01c1e969b4430ca1b08ede7dab5f91a64a306e321f0348667446e1b3597e", + Verifier: []byte("DdAaVTKxoYgxzWY2UWrdl1xHOOv4ZUozra4Vm8WGxmU="), + Proof: &CredentialProofBackupCode{}, + Metadata: map[string]string{ + CredentialMetadataAWSKMSKey: "test", + }, + }, + err: ErrInvalidMetadataKey(CredentialMetadataAWSKMSKey, CredentialTypeBackupCode), + }, } - for name, tc := range cases { - t.Run(name, func(t *testing.T) { + for Description, tc := range cases { + t.Run(Description, func(t *testing.T) { // Do err := tc.req.Validate() diff --git a/internals/api/encrypted_data.go b/internals/api/encrypted_data.go index 072f6d60..6cfe887c 100644 --- a/internals/api/encrypted_data.go +++ b/internals/api/encrypted_data.go @@ -133,6 +133,8 @@ func (ed *EncryptedData) UnmarshalJSON(b []byte) error { dec.Key = &EncryptionKeyEncrypted{} case KeyTypeLocal: dec.Key = &EncryptionKeyLocal{} + case KeyTypeBootstrapCode: + dec.Key = &EncryptionKeyBootstrapCode{} case KeyTypeAccountKey: dec.Key = &EncryptionKeyAccountKey{} case KeyTypeSecretKey: @@ -220,3 +222,33 @@ func (ed *EncryptedData) Validate() error { } return nil } + +// EncryptedDataAESGCM is a typed EncryptedData for the AESGCM algorithm. +type EncryptedDataAESGCM struct { + Key interface{} + Parameters EncryptionParametersAESGCM + Metadata EncryptionMetadataAESGCM + Ciphertext []byte +} + +// AESGCM casts the EncryptedData to EncryptedDataAESGCM. +// Returns an error if the EncryptedData does not have AESGCM as its algorithm. +func (ed *EncryptedData) AESGCM() (*EncryptedDataAESGCM, error) { + if ed.Algorithm != EncryptionAlgorithmAESGCM { + return nil, ErrInvalidEncryptionAlgorithm + } + parameters, ok := ed.Parameters.(*EncryptionParametersAESGCM) + if !ok { + return nil, ErrInvalidEncryptionAlgorithm + } + metadata, ok := ed.Metadata.(*EncryptionMetadataAESGCM) + if !ok { + return nil, ErrInvalidEncryptionAlgorithm + } + return &EncryptedDataAESGCM{ + Key: ed.Key, + Parameters: *parameters, + Metadata: *metadata, + Ciphertext: ed.Ciphertext, + }, nil +} diff --git a/internals/api/encrypted_data_test.go b/internals/api/encrypted_data_test.go index 87d2a3fb..457fa1a5 100644 --- a/internals/api/encrypted_data_test.go +++ b/internals/api/encrypted_data_test.go @@ -35,6 +35,9 @@ func TestEncryptedData_MarshalUnmarshalValidate(t *testing.T) { "aes with scrypt": { in: NewEncryptedDataAESGCM([]byte("ciphertext"), []byte("nonce"), 96, NewEncryptionKeyDerivedScrypt(256, 1, 2, 3, []byte("just-a-salt"))), }, + "aes with bootstrap code": { + in: NewEncryptedDataAESGCM([]byte("ciphertext"), []byte("nonce"), 96, NewEncryptionKeyBootstrapCode(256)), + }, "rsa with missing key": { in: NewEncryptedDataAESGCM([]byte("ciphertext"), []byte("nonce"), 96, nil), expectedErr: ErrInvalidKeyType, diff --git a/internals/api/encryption_key.go b/internals/api/encryption_key.go index 152eed40..340b1abb 100644 --- a/internals/api/encryption_key.go +++ b/internals/api/encryption_key.go @@ -37,12 +37,13 @@ func (ed *KeyDerivationAlgorithm) UnmarshalJSON(b []byte) error { // Options for KeyType const ( - KeyTypeDerived KeyType = "derived" - KeyTypeEncrypted KeyType = "encrypted" - KeyTypeLocal KeyType = "local" - KeyTypeAccountKey KeyType = "account-key" - KeyTypeSecretKey KeyType = "secret-key" - KeyTypeAWS KeyType = "aws" + KeyTypeDerived KeyType = "derived" + KeyTypeEncrypted KeyType = "encrypted" + KeyTypeLocal KeyType = "local" + KeyTypeAccountKey KeyType = "account-key" + KeyTypeSecretKey KeyType = "secret-key" + KeyTypeAWS KeyType = "aws" + KeyTypeBootstrapCode KeyType = "bootstrap-code" ) // Options for KeyDerivationAlgorithm @@ -243,6 +244,38 @@ func (k EncryptionKeyLocal) Validate() error { return nil } +// NewEncryptionKeyLocal creates a EncryptionKeyBootstrapCode. +func NewEncryptionKeyBootstrapCode(length int) *EncryptionKeyBootstrapCode { + return &EncryptionKeyBootstrapCode{ + EncryptionKey: EncryptionKey{ + Type: KeyTypeBootstrapCode, + }, + Length: length, + } +} + +// EncryptionKeyBootstrapCode is an encryption key that is stored as a code memorized by the user. +type EncryptionKeyBootstrapCode struct { + EncryptionKey + Length int `json:"length"` +} + +// SupportsAlgorithm returns true when the encryption key supports the given algorithm. +func (EncryptionKeyBootstrapCode) SupportsAlgorithm(a EncryptionAlgorithm) bool { + return a == EncryptionAlgorithmAESGCM +} + +// Validate whether the EncryptionKeyBootstrapCode is valid. +func (k EncryptionKeyBootstrapCode) Validate() error { + if k.Length == 0 { + return ErrMissingField("length") + } + if k.Length <= 0 { + return ErrInvalidKeyLength + } + return nil +} + // NewEncryptionKeyAccountKey creates a EncryptionKeyAccountKey. func NewEncryptionKeyAccountKey(length int, id uuid.UUID) *EncryptionKeyAccountKey { return &EncryptionKeyAccountKey{ diff --git a/internals/api/patterns.go b/internals/api/patterns.go index aa754c5d..b240a3b1 100644 --- a/internals/api/patterns.go +++ b/internals/api/patterns.go @@ -47,6 +47,8 @@ var ( whitelistSecretPathInDirPath = regexp.MustCompile(fmt.Sprintf(`((?i)^(%s\/%s\/%s(\/%s)*(?:\:.+)?)$)`, patternUniformName, patternUniformName, patternUniformName, patternUniformName)) whitelistSecretVersionIdentifierInSecretPath = regexp.MustCompile(fmt.Sprintf(`(?i)^(%s)\/(%s)\/(%s\/)*(%s)(:(.+)?)$`, patternUniformName, patternUniformName, patternUniformName, patternUniformName)) whitelistSecretVersionInSecretPath = regexp.MustCompile(fmt.Sprintf(`(?i)^(%s)\/(%s)\/(%s\/)*(%s)(:([0-9]{1,9}|latest))$`, patternUniformName, patternUniformName, patternUniformName, patternUniformName)) + + whitelistCredentialFingerprint = regexp.MustCompile("^[0-9a-fA-F]{1,64}$") ) // Errors @@ -74,6 +76,10 @@ var ( "directory roles must be either read, write, or admin", http.StatusBadRequest, ) + ErrInvalidCredentialFingerprint = errAPI.Code("invalid_credential_fingerprint").StatusError( + "credential fingerprint must consist of 64 hexadecimal characters", + http.StatusBadRequest, + ) ) // ValidateNamespace validates a username. @@ -251,3 +257,37 @@ func ValidateDirPath(path string) error { return nil } + +// ValidateCredentialDescription validates the description for a credential. +func ValidateCredentialDescription(description string) error { + if len(description) < 1 || len(description) > 32 { + return ErrInvalidCredentialDescription + } + if !whitelistDescription.MatchString(description) { + return ErrInvalidCredentialDescription + } + return nil +} + +// ValidateCredentialFingerprint validates whether the given string is a valid credential fingerprint. +func ValidateCredentialFingerprint(fingerprint string) error { + if !whitelistCredentialFingerprint.MatchString(fingerprint) { + return ErrInvalidFingerprint + } + if len(fingerprint) != 64 { + return ErrInvalidFingerprint + } + return nil +} + +// ValidateShortCredentialFingerprint validates whether the given string can be used as a short version of a credential +// fingerprint. +func ValidateShortCredentialFingerprint(fingerprint string) error { + if !whitelistCredentialFingerprint.MatchString(fingerprint) { + return ErrInvalidFingerprint + } + if len(fingerprint) < ShortCredentialFingerprintMinimumLength { + return ErrTooShortFingerprint + } + return nil +} diff --git a/internals/api/patterns_test.go b/internals/api/patterns_test.go index 422764f8..73e870c2 100644 --- a/internals/api/patterns_test.go +++ b/internals/api/patterns_test.go @@ -427,3 +427,40 @@ func TestValidateSecretPath(t *testing.T) { } } } + +func TestValidateCredentialFingerprint(t *testing.T) { + cases := map[string]struct { + in string + expected error + }{ + "valid lowercase": { + in: "d9db31d1bfd9a8a55a4dd715501017fd8d2c33025cb05049664eaf195dafb801", + }, + "valid uppercase": { + in: "D9DB31D1BFD9A8A55A4DD715501017FD8D2C33025CB05049664EAF195DAFB801", + }, + "valid mixed case": { + in: "d9db31d1bfd9a8a55a4dd715501017FD8D2C33025CB05049664EAF195DAFB801", + }, + "too short": { + in: "d9db31d1bfd9a8a55a4dd715501017fd8d2c33025cb05049664eaf195dafb80", + expected: api.ErrInvalidFingerprint, + }, + "too long": { + in: "d9db31d1bfd9a8a55a4dd715501017fd8d2c33025cb05049664eaf195dafb801b", + expected: api.ErrInvalidFingerprint, + }, + "illegal character": { + in: "Q9db31d1bfd9a8a55a4dd715501017fd8d2c33025cb05049664eaf195dafb801", + expected: api.ErrInvalidFingerprint, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := api.ValidateCredentialFingerprint(tc.in) + + assert.Equal(t, err, tc.expected) + }) + } +} diff --git a/internals/api/user_test.go b/internals/api/user_test.go index 41ca9e28..feef638a 100644 --- a/internals/api/user_test.go +++ b/internals/api/user_test.go @@ -189,7 +189,6 @@ func TestCreateUserRequest_Validate(t *testing.T) { Email: "test-account.dev1@secrethub.io", FullName: "Test Tester", Credential: &CreateCredentialRequest{ - Name: "Personal laptop credential", Type: CredentialTypeKey, Fingerprint: "88c9eae68eb300b2971a2bec9e5a26ff4179fd661d6b7d861e4c6557b9aaee14", Verifier: []byte("verifier"), diff --git a/internals/aws/service_creator.go b/internals/aws/service_creator.go index 70b7aac1..43921046 100644 --- a/internals/aws/service_creator.go +++ b/internals/aws/service_creator.go @@ -71,11 +71,7 @@ func (c CredentialCreator) Type() api.CredentialType { // Verifier returns the verifier of an AWS service. func (c CredentialCreator) Export() ([]byte, string, error) { verifier := []byte(c.role) - - fingerprint, err := api.GetFingerprint(c.Type(), verifier) - if err != nil { - return nil, "", err - } + fingerprint := api.GetFingerprint(c.Type(), verifier) return verifier, fingerprint, nil } diff --git a/internals/crypto/hash.go b/internals/crypto/hash.go new file mode 100644 index 00000000..6f669635 --- /dev/null +++ b/internals/crypto/hash.go @@ -0,0 +1,9 @@ +package crypto + +import "crypto/sha256" + +// SHA256 creates a SHA256 hash of the given bytes. +func SHA256(in []byte) []byte { + hash := sha256.Sum256(in) + return hash[:] +} diff --git a/pkg/secrethub/client.go b/pkg/secrethub/client.go index 6bcaccb5..34b6d551 100644 --- a/pkg/secrethub/client.go +++ b/pkg/secrethub/client.go @@ -32,6 +32,8 @@ type ClientInterface interface { AccessRules() AccessRuleService // Accounts returns a service used to manage SecretHub accounts. Accounts() AccountService + // Credentials returns a service used to manage credentials. + Credentials() CredentialService // Dirs returns a service used to manage directories. Dirs() DirService // Me returns a service used to manage the current authenticated account. @@ -168,6 +170,11 @@ func (c *Client) Accounts() AccountService { return newAccountService(c) } +// Credentials returns a service used to manage credentials. +func (c *Client) Credentials() CredentialService { + return newCredentialService(c) +} + // Dirs returns a service used to manage directories. func (c *Client) Dirs() DirService { return newDirService(c) diff --git a/pkg/secrethub/credentials.go b/pkg/secrethub/credentials.go new file mode 100644 index 00000000..311462d9 --- /dev/null +++ b/pkg/secrethub/credentials.go @@ -0,0 +1,135 @@ +package secrethub + +import ( + "github.com/secrethub/secrethub-go/pkg/secrethub/iterator" + + "github.com/secrethub/secrethub-go/internals/api" + "github.com/secrethub/secrethub-go/pkg/secrethub/credentials" +) + +// CredentialService handles operations on credentials on SecretHub. +type CredentialService interface { + // Create a new credential from the credentials.Creator for an existing account. + Create(credentials.Creator, string) (*api.Credential, error) + // List lists all credentials of the currently authenticated account. + List(_ *CredentialListParams) CredentialIterator + // Disable an existing credential. + Disable(fingerprint string) error +} + +func newCredentialService(client *Client) CredentialService { + return credentialService{ + client: client, + } +} + +type credentialService struct { + client *Client +} + +// Create a new credential from the credentials.Creator for an existing account. +// This includes a re-encrypted copy the the account key. +// Description is optional and can be left empty. +func (s credentialService) Create(creator credentials.Creator, description string) (*api.Credential, error) { + accountKey, err := s.client.getAccountKey() + if err != nil { + return nil, err + } + + err = creator.Create() + if err != nil { + return nil, err + } + + verifier := creator.Verifier() + bytes, fingerprint, err := verifier.Export() + if err != nil { + return nil, err + } + + accountKeyRequest, err := s.client.createAccountKeyRequest(creator.Encrypter(), *accountKey) + if err != nil { + return nil, err + } + + var reqDescription *string + if description != "" { + reqDescription = &description + } + + req := api.CreateCredentialRequest{ + Fingerprint: fingerprint, + Verifier: bytes, + Description: reqDescription, + Type: verifier.Type(), + Metadata: creator.Metadata(), + AccountKey: accountKeyRequest, + } + err = verifier.AddProof(&req) + if err != nil { + return nil, err + } + + err = req.Validate() + if err != nil { + return nil, err + } + + return s.client.httpClient.CreateCredential(&req) +} + +// CredentialListParams are the parameters that configure credential listing. +type CredentialListParams struct{} + +// CredentialIterator can be used to iterate over a list of credentials. +type CredentialIterator interface { + Next() (api.Credential, error) +} + +type credentialIterator struct { + credentials []*api.Credential + currentIndex int + err error +} + +func (c *credentialIterator) Next() (api.Credential, error) { + if c.err != nil { + return api.Credential{}, c.err + } + + currentIndex := c.currentIndex + if currentIndex >= len(c.credentials) { + return api.Credential{}, iterator.Done + } + c.currentIndex++ + return *c.credentials[currentIndex], nil +} + +// List returns an iterator that lists all credentials of the currently authenticated account. +func (s credentialService) List(_ *CredentialListParams) CredentialIterator { + creds, err := s.client.httpClient.ListMyCredentials() + return &credentialIterator{ + credentials: creds, + err: err, + } +} + +// Disable an existing credential. +func (s credentialService) Disable(fingerprint string) error { + err := api.ValidateShortCredentialFingerprint(fingerprint) + if err != nil { + return err + } + + f := false + req := &api.UpdateCredentialRequest{ + Enabled: &f, + } + err = req.Validate() + if err != nil { + return err + } + + _, err = s.client.httpClient.UpdateCredential(fingerprint, req) + return err +} diff --git a/pkg/secrethub/credentials/bootstrap_code.go b/pkg/secrethub/credentials/bootstrap_code.go new file mode 100644 index 00000000..be321fc9 --- /dev/null +++ b/pkg/secrethub/credentials/bootstrap_code.go @@ -0,0 +1,196 @@ +package credentials + +import ( + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/secrethub/secrethub-go/internals/api" + "github.com/secrethub/secrethub-go/internals/auth" + "github.com/secrethub/secrethub-go/internals/crypto" + "github.com/secrethub/secrethub-go/pkg/secrethub/internals/http" +) + +var ( + bootstrapCodeRegexp = regexp.MustCompile("[^a-zA-Z0-9]+") +) + +// Enforce implementation of interfaces by structs. +var _ Creator = (*BackupCodeCreator)(nil) +var _ Provider = (*bootstrapCodeProvider)(nil) +var _ auth.Signer = (*bootstrapCode)(nil) + +// BackupCodeCreator creates a new credential based on a backup code. +type BackupCodeCreator struct { + bootstrapCode *bootstrapCode +} + +// CreateBackupCode returns a Creator that creates a backup code credential. +func CreateBackupCode() *BackupCodeCreator { + return &BackupCodeCreator{} +} + +// ValidateBootstrapCode validates a string and checks whether it is a valid bootstrap code. +func ValidateBootstrapCode(code string) error { + filtered := filterBootstrapCode(code) + if len(filtered) != crypto.SymmetricKeyLength*2 { + return errors.New("code does not consist of 64 hexadecimal characters") + } + return nil +} + +// Create generates a new code and stores it in the BackupCodeCreator. +func (b *BackupCodeCreator) Create() error { + key, err := crypto.GenerateSymmetricKey() + if err != nil { + return err + } + b.bootstrapCode = newBootstrapCode(key.Export(), api.CredentialTypeBackupCode) + return nil +} + +// Code returns the string representation of the backup code. +// Can only be called after the credential has been created. +func (b *BackupCodeCreator) Code() (string, error) { + if b.bootstrapCode == nil { + return "", errors.New("backup code has not yet been generated") + } + code := strings.ToUpper(hex.EncodeToString(b.bootstrapCode.encryptionKey.Export())) + delimitedCode := strings.Join(splitStringByWidth(code, 8), "-") + return delimitedCode, nil +} + +// Verifier returns a Verifier that can be used for creating a new credential from this backup code. +func (b *BackupCodeCreator) Verifier() Verifier { + return b.bootstrapCode +} + +// Encrypter returns a Encrypter that can be used to encrypt data with this backup code. +func (b *BackupCodeCreator) Encrypter() Encrypter { + return b.bootstrapCode +} + +// Metadata returns the metadata for a backup code. +func (b *BackupCodeCreator) Metadata() map[string]string { + return nil +} + +// bootstrapCodeProvider is a Provider that can be used to authenticate and decrypt with a bootstrap code. +type bootstrapCodeProvider struct { + code string + t api.CredentialType +} + +// UseBackupCode returns a Provider for authentication and decryption with the given backup code. +func UseBackupCode(code string) Provider { + return &bootstrapCodeProvider{ + code: code, + t: api.CredentialTypeBackupCode, + } +} + +// Provide returns the auth.Authenticator and Decrypter corresponding to a bootstrap code. +func (b *bootstrapCodeProvider) Provide(_ *http.Client) (auth.Authenticator, Decrypter, error) { + err := ValidateBootstrapCode(b.code) + if err != nil { + return nil, nil, fmt.Errorf("malformed code: %v", err) + } + bytes, err := hex.DecodeString(filterBootstrapCode(b.code)) + if err != nil { + return nil, nil, fmt.Errorf("malformed code: %v", err) + } + bootstrapCode := newBootstrapCode(bytes, b.t) + return auth.NewHTTPSigner(bootstrapCode), bootstrapCode, nil +} + +// bootstrapCode is a type that represents both backup and enroll codes. +type bootstrapCode struct { + t api.CredentialType + encryptionKey *crypto.SymmetricKey + signKey *crypto.SymmetricKey +} + +// newBootstrapCode returns a new bootstrapCode for the given AES key and credential type. +func newBootstrapCode(key []byte, t api.CredentialType) *bootstrapCode { + encryptionKey := crypto.NewSymmetricKey(key) + signKey := crypto.NewSymmetricKey(crypto.SHA256(key)) + return &bootstrapCode{ + t: t, + encryptionKey: encryptionKey, + signKey: signKey, + } +} + +func (b *bootstrapCode) Export() ([]byte, string, error) { + verifierBytes := []byte(base64.StdEncoding.EncodeToString(b.signKey.Export())) + fingerprint := api.GetFingerprint(b.t, verifierBytes) + return verifierBytes, fingerprint, nil +} + +func (b *bootstrapCode) Type() api.CredentialType { + return b.t +} + +func (b *bootstrapCode) AddProof(req *api.CreateCredentialRequest) error { + return nil +} + +func (b *bootstrapCode) ID() (string, error) { + _, fingerprint, err := b.Export() + if err != nil { + return "", err + } + return fingerprint, nil +} + +func (b *bootstrapCode) Sign(in []byte) ([]byte, error) { + return b.signKey.HMAC(in) +} + +func (b *bootstrapCode) SignMethod() string { + return "BootstrapCode-HMAC" +} + +func (b *bootstrapCode) Wrap(plaintext []byte) (*api.EncryptedData, error) { + enc, err := b.encryptionKey.Encrypt(plaintext) + if err != nil { + return nil, err + } + return api.NewEncryptedDataAESGCM(enc.Data, enc.Nonce, len(enc.Nonce)*8, api.NewEncryptionKeyBootstrapCode(256)), nil +} + +func (b *bootstrapCode) Unwrap(ciphertext *api.EncryptedData) ([]byte, error) { + ciphertextAESGCM, err := ciphertext.AESGCM() + if err != nil { + return nil, err + } + decrypted, err := b.encryptionKey.Decrypt(crypto.CiphertextAES{ + Data: ciphertextAESGCM.Ciphertext, + Nonce: ciphertextAESGCM.Metadata.Nonce, + }) + if err != nil { + return nil, err + } + return decrypted, nil +} + +func filterBootstrapCode(code string) string { + return bootstrapCodeRegexp.ReplaceAllString(code, "") +} + +func splitStringByWidth(in string, width int) []string { + var out []string + tmp := "" + for i, r := range in { + tmp += string(r) + + if (i+1)%width == 0 { + out = append(out, tmp) + tmp = "" + } + } + return out +} diff --git a/pkg/secrethub/credentials/rsa.go b/pkg/secrethub/credentials/rsa.go index 044f4c67..90e94c0e 100644 --- a/pkg/secrethub/credentials/rsa.go +++ b/pkg/secrethub/credentials/rsa.go @@ -34,10 +34,7 @@ func (c RSACredential) Export() ([]byte, string, error) { if err != nil { return nil, "", err } - fingerprint, err := api.GetFingerprint(c.Type(), verifier) - if err != nil { - return nil, "", err - } + fingerprint := api.GetFingerprint(c.Type(), verifier) return verifier, fingerprint, nil } diff --git a/pkg/secrethub/fakeclient/client.go b/pkg/secrethub/fakeclient/client.go index c9416ce2..aab68550 100644 --- a/pkg/secrethub/fakeclient/client.go +++ b/pkg/secrethub/fakeclient/client.go @@ -16,7 +16,7 @@ type Client struct { SecretService *SecretService ServiceService *ServiceService UserService *UserService - secrethub.Client + secrethub.ClientInterface } // AccessRules implements the secrethub.Client interface. diff --git a/pkg/secrethub/internals/http/client.go b/pkg/secrethub/internals/http/client.go index f59a0214..a2749f8d 100644 --- a/pkg/secrethub/internals/http/client.go +++ b/pkg/secrethub/internals/http/client.go @@ -43,6 +43,8 @@ const ( // Account pathAccount = "%s/account/%s" + pathCredentials = "%s/me/credentials" + pathCredential = "%s/me/credentials/%s" pathCreateAccountKey = "%s/me/credentials/%s/key" // Users @@ -167,6 +169,30 @@ func (c *Client) GetMyUser() (*api.User, error) { return out, errio.Error(err) } +// CreateCredential creates a new credential for the account. +func (c *Client) CreateCredential(in *api.CreateCredentialRequest) (*api.Credential, error) { + out := &api.Credential{} + rawURL := fmt.Sprintf(pathCredentials, c.base.String()) + err := c.post(rawURL, true, http.StatusCreated, in, out) + return out, errio.Error(err) +} + +// ListMyCredentials list all the currently authenticated account's credentials. +func (c *Client) ListMyCredentials() ([]*api.Credential, error) { + var out []*api.Credential + rawURL := fmt.Sprintf(pathCredentials, c.base.String()) + err := c.get(rawURL, true, &out) + return out, errio.Error(err) +} + +// UpdateCredential updates an existing credential. +func (c *Client) UpdateCredential(fingerprint string, in *api.UpdateCredentialRequest) (*api.Credential, error) { + var out api.Credential + rawURL := fmt.Sprintf(pathCredential, c.base.String(), fingerprint) + err := c.patch(rawURL, true, http.StatusOK, in, &out) + return &out, err +} + // SendVerificationEmail sends an email to the users registered email address for them to prove they // own that email address. func (c *Client) SendVerificationEmail() error {