diff --git a/internal/darwin/security/lacontext_darwin.go b/internal/darwin/security/lacontext_darwin.go new file mode 100644 index 00000000..00c32e79 --- /dev/null +++ b/internal/darwin/security/lacontext_darwin.go @@ -0,0 +1,108 @@ +// Copyright (c) Smallstep Labs, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not +// use this file except in compliance with the License. You may obtain a copy of +// the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 + +//nolint:gocritic // mirrors security_darwin.go style +package security + +/* +#cgo CFLAGS: -x objective-c -fno-objc-arc +#cgo LDFLAGS: -framework Foundation -framework LocalAuthentication + +#import +#import +#import + +static CFTypeRef smallstepNewLAContext(void) { + LAContext *ctx = [[LAContext alloc] init]; + return (CFTypeRef)ctx; +} + +static void smallstepLAContextRelease(CFTypeRef ref) { + if (ref == NULL) return; + LAContext *ctx = (LAContext *)ref; + [ctx release]; +} + +static void smallstepLAContextSetLocalizedReason(CFTypeRef ref, const char *reason) { + if (ref == NULL || reason == NULL) return; + LAContext *ctx = (LAContext *)ref; + ctx.localizedReason = [NSString stringWithUTF8String:reason]; +} + +static void smallstepLAContextSetReuseDuration(CFTypeRef ref, double seconds) { + if (ref == NULL) return; + LAContext *ctx = (LAContext *)ref; + ctx.touchIDAuthenticationAllowableReuseDuration = (NSTimeInterval)seconds; +} +*/ +import "C" + +import ( + "time" + "unsafe" + + cf "go.step.sm/crypto/internal/darwin/corefoundation" +) + +// LAContextMaxReuseDuration mirrors +// LATouchIDAuthenticationMaximumAllowableReuseDuration (300s). Reuse durations +// larger than this are silently clamped by the platform. +const LAContextMaxReuseDuration = 300 * time.Second + +// LAContextRef wraps an LAContext object so it can be passed to a keychain +// operation via the kSecUseAuthenticationContext attribute. +// +// The reference owns one retain on the underlying Objective-C object; call +// Release exactly once when finished. +type LAContextRef struct { + ref C.CFTypeRef +} + +// NewLAContext creates a new LAContext with the given localized reason and +// reuse duration. An empty reason leaves the LAContext's reason unset (macOS +// will surface a default). A non-positive reuseDuration leaves the underlying +// touchIDAuthenticationAllowableReuseDuration at its default of 0 (no caching); +// values above LAContextMaxReuseDuration are clamped. +func NewLAContext(reason string, reuseDuration time.Duration) *LAContextRef { + ref := C.smallstepNewLAContext() + if ref == 0 { + return nil + } + if reason != "" { + cReason := C.CString(reason) + C.smallstepLAContextSetLocalizedReason(ref, cReason) + C.free(unsafe.Pointer(cReason)) + } + if reuseDuration > 0 { + if reuseDuration > LAContextMaxReuseDuration { + reuseDuration = LAContextMaxReuseDuration + } + C.smallstepLAContextSetReuseDuration(ref, C.double(reuseDuration.Seconds())) + } + return &LAContextRef{ref: ref} +} + +// Release frees the underlying LAContext. Safe to call on a nil receiver and +// idempotent — only the first call releases. +func (l *LAContextRef) Release() { + if l == nil || l.ref == 0 { + return + } + C.smallstepLAContextRelease(l.ref) + l.ref = 0 +} + +// TypeRef exposes the underlying object as a CFTypeRef so callers can place it +// in a CFDictionary under kSecUseAuthenticationContext. Returns 0 if the +// receiver is nil or has been released. +func (l *LAContextRef) TypeRef() cf.CFTypeRef { + if l == nil { + return 0 + } + return cf.CFTypeRef(l.ref) +} diff --git a/internal/darwin/security/security_darwin.go b/internal/darwin/security/security_darwin.go index 255960dd..9b73713d 100644 --- a/internal/darwin/security/security_darwin.go +++ b/internal/darwin/security/security_darwin.go @@ -85,6 +85,7 @@ var ( KSecPrivateKeyAttrs = cf.TypeRef(C.kSecPrivateKeyAttrs) KSecReturnRef = cf.TypeRef(C.kSecReturnRef) KSecReturnAttributes = cf.TypeRef(C.kSecReturnAttributes) + KSecUseAuthenticationContext = cf.TypeRef(C.kSecUseAuthenticationContext) KSecValueRef = cf.TypeRef(C.kSecValueRef) KSecValueData = cf.TypeRef(C.kSecValueData) ) diff --git a/kms/mackms/mackms.go b/kms/mackms/mackms.go index c2c28003..7e1e90ba 100644 --- a/kms/mackms/mackms.go +++ b/kms/mackms/mackms.go @@ -52,6 +52,23 @@ const Scheme = string(apiv1.MacKMS) // the keys. var DefaultTag = "com.smallstep.crypto" +// authPolicy describes the user-authorization requirement enforced by the +// Secure Enclave for a key's signing operations. +type authPolicy int + +const ( + // authPolicyNone disables user-authorization. The Secure Enclave signs + // without prompting. + authPolicyNone authPolicy = iota + // authPolicyUserPresence maps to kSecAccessControlUserPresence: any of + // Touch ID, Apple Watch, or device passcode satisfies the requirement. + authPolicyUserPresence + // authPolicyUserVerification maps to kSecAccessControlBiometryCurrentSet: + // Touch ID against the currently enrolled biometric set. The key is + // invalidated if the biometric enrollment changes. + authPolicyUserVerification +) + type keyAttributes struct { label string tag string @@ -59,6 +76,9 @@ type keyAttributes struct { retry bool useSecureEnclave bool useBiometrics bool + authPolicy authPolicy + authReuse time.Duration + authReason string sigAlgorithm apiv1.SignatureAlgorithm keySize int } @@ -75,6 +95,9 @@ func (k *keyAttributes) retryAttributes() *keyAttributes { label: k.label, hash: k.hash, useSecureEnclave: k.useSecureEnclave, + authPolicy: k.authPolicy, + authReuse: k.authReuse, + authReason: k.authReason, retry: false, } } @@ -120,6 +143,7 @@ var signatureAlgorithmMapping = map[apiv1.SignatureAlgorithm]algorithmAttributes // - mackms:label=my-name;tag=com.smallstep.crypto // - mackms;label=my-name;tag= // - mackms;label=my-name;se=true;bio=true +// - mackms:label=my-name;se=true;policy=user-presence;cache=60s // // GetPublicKey and CreateSigner accepts the above URIs as well as the following // ones: @@ -137,8 +161,23 @@ var signatureAlgorithmMapping = map[apiv1.SignatureAlgorithm]algorithmAttributes // Secure Enclave. This option requires the application to be code-signed // with the appropriate entitlements. // - "bio" is a boolean value. If set to true, sign and verify operations -// require Touch ID or Face ID. This options requires the key to be in the -// Secure Enclave. +// require Touch ID or Face ID against the currently enrolled biometric +// set. This option requires the key to be in the Secure Enclave. Equivalent +// to policy=user-verification. +// - "policy" sets the user-authorization requirement enforced by the Secure +// Enclave. Requires se=true. Valid values: +// "none" (default) — no prompt; +// "user-presence" — kSecAccessControlUserPresence (Touch ID, Apple Watch, +// or device passcode); +// "user-verification" — kSecAccessControlBiometryCurrentSet (Touch ID +// against the currently enrolled set; the key is invalidated if the +// biometric enrollment changes). +// - "cache" is a Go duration string (e.g. "60s"). When set, a successful +// authorization is cached for this long before the user is re-prompted. +// Maps to LAContext.touchIDAuthenticationAllowableReuseDuration; macOS +// clamps values larger than 300s. +// - "reason" is the localizedReason shown in the macOS authentication +// prompt. Only meaningful when policy is set. // - "hash" corresponds with kSecAttrApplicationLabel. It is the SHA-1 of the // DER representation of an RSA public key using the PKCS #1 format or the // SHA-1 of the uncompressed ECDSA point according to SEC 1, Version 2.0, @@ -190,7 +229,7 @@ func (k *MacKMS) GetPublicKey(req *apiv1.GetPublicKeyRequest) (crypto.PublicKey, return nil, fmt.Errorf("mackms GetPublicKey failed: %w", err) } - key, err := getPrivateKey(u) + key, err := getPrivateKey(u, nil) if err != nil { return nil, fmt.Errorf("mackms GetPublicKey failed: %w", apiv1Error(err)) } @@ -251,6 +290,14 @@ func (k *MacKMS) CreateKey(req *apiv1.CreateKeyRequest) (*apiv1.CreateKeyRespons defer cfTag.Release() keyAttributesDict[security.KSecAttrApplicationTag] = cfTag } + // Authorization policies require a Secure Enclave key — there's no + // software-only fallback that can enforce the policy at the hardware + // level. Reject the combination at creation time rather than silently + // downgrading. + if !u.useSecureEnclave && u.authPolicy != authPolicyNone { + return nil, fmt.Errorf("createKeyRequest: authorization policies require the Secure Enclave (se=true)") + } + if u.useSecureEnclave { // After the first unlock, the data remains accessible until the next // restart. This is recommended for items that need to be accessed by @@ -260,9 +307,15 @@ func (k *MacKMS) CreateKey(req *apiv1.CreateKeyRequest) (*apiv1.CreateKeyRespons // // TODO: make this a configuration option flags := security.KSecAccessControlPrivateKeyUsage - if u.useBiometrics { + switch { + case u.authPolicy == authPolicyUserVerification, u.useBiometrics: + // bio=true is preserved as a backwards-compatible alias for + // policy=user-verification. flags |= security.KSecAccessControlAnd flags |= security.KSecAccessControlBiometryCurrentSet + case u.authPolicy == authPolicyUserPresence: + flags |= security.KSecAccessControlAnd + flags |= security.KSecAccessControlUserPresence } access, err := security.SecAccessControlCreateWithFlags( security.KSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, @@ -337,6 +390,18 @@ func (k *MacKMS) CreateKey(req *apiv1.CreateKeyRequest) (*apiv1.CreateKeyRespons if u.useBiometrics { name.Values.Set("bio", "true") } + switch u.authPolicy { + case authPolicyUserPresence: + name.Values.Set("policy", "user-presence") + case authPolicyUserVerification: + name.Values.Set("policy", "user-verification") + } + if u.authReuse > 0 { + name.Values.Set("cache", u.authReuse.String()) + } + if u.authReason != "" { + name.Values.Set("reason", u.authReason) + } return &apiv1.CreateKeyResponse{ Name: name.String(), @@ -359,7 +424,7 @@ func (k *MacKMS) CreateSigner(req *apiv1.CreateSignerRequest) (crypto.Signer, er return nil, fmt.Errorf("mackms CreateSigner failed: %w", err) } - key, err := getPrivateKey(u) + key, err := getPrivateKey(u, nil) if err != nil { return nil, fmt.Errorf("mackms CreateSigner failed: %w", apiv1Error(err)) } @@ -778,7 +843,7 @@ func deleteItem(dict cf.Dictionary, hash []byte) error { return nil } -func getPrivateKey(u *keyAttributes) (*security.SecKeyRef, error) { +func getPrivateKey(u *keyAttributes, laCtx *security.LAContextRef) (*security.SecKeyRef, error) { dict := cf.Dictionary{ security.KSecClass: security.KSecClassKey, security.KSecAttrKeyClass: security.KSecAttrKeyClassPrivate, @@ -814,6 +879,9 @@ func getPrivateKey(u *keyAttributes) (*security.SecKeyRef, error) { } else { dict[security.KSecUseDataProtectionKeychain] = cf.False } + if laCtx != nil { + dict[security.KSecUseAuthenticationContext] = laCtx + } // Get the query from the keychain query, err := cf.NewDictionary(dict) @@ -827,7 +895,7 @@ func getPrivateKey(u *keyAttributes) (*security.SecKeyRef, error) { // If not found retry without the tag if it wasn't set. if errors.Is(err, security.ErrNotFound) { if ru := u.retryAttributes(); ru != nil { - return getPrivateKey(ru) + return getPrivateKey(ru, laCtx) } } return nil, fmt.Errorf("macOS SecItemCopyMatching failed: %w", err) @@ -1232,6 +1300,7 @@ func parseURI(rawuri string) (*keyAttributes, error) { // With regular values, uris look like this: // mackms:label=my-key;tag=my-tag;hash=010a...;se=true;bio=true + // mackms:label=my-key;se=true;policy=user-presence;cache=60s label := u.Get("label") if label == "" { return nil, fmt.Errorf("error parsing %q: label is required", rawuri) @@ -1240,14 +1309,40 @@ func parseURI(rawuri string) (*keyAttributes, error) { if tag == "" && !u.Has("tag") { tag = DefaultTag } - return &keyAttributes{ + + attrs := &keyAttributes{ label: label, tag: tag, hash: u.GetEncoded("hash"), retry: !u.Has("tag"), useSecureEnclave: u.GetBool("se"), useBiometrics: u.GetBool("bio"), - }, nil + authReason: u.Get("reason"), + } + + switch strings.ToLower(u.Get("policy")) { + case "", "none": + // leave authPolicyNone + case "user-presence": + attrs.authPolicy = authPolicyUserPresence + case "user-verification": + attrs.authPolicy = authPolicyUserVerification + default: + return nil, fmt.Errorf("error parsing %q: unknown policy %q", rawuri, u.Get("policy")) + } + + if d := u.Get("cache"); d != "" { + cache, err := time.ParseDuration(d) + if err != nil { + return nil, fmt.Errorf("error parsing %q: invalid cache duration: %w", rawuri, err) + } + if cache < 0 { + return nil, fmt.Errorf("error parsing %q: cache duration must be non-negative", rawuri) + } + attrs.authReuse = cache + } + + return attrs, nil } func parseCertURI(rawuri string, useDataProtectionKeychain, requireValue bool) (*certAttributes, error) { diff --git a/kms/mackms/mackms_test.go b/kms/mackms/mackms_test.go index adca47ec..f8bd3e4e 100644 --- a/kms/mackms/mackms_test.go +++ b/kms/mackms/mackms_test.go @@ -447,6 +447,9 @@ func TestMacKMS_CreateKey(t *testing.T) { {"fail signatureAlgorithm secureEnclave", &MacKMS{}, args{&apiv1.CreateKeyRequest{ Name: "mackms:label=test-p256;se=true", SignatureAlgorithm: apiv1.ECDSAWithSHA512, }}, require.Nil, assert.Error}, + {"fail policy without secureEnclave", &MacKMS{}, args{&apiv1.CreateKeyRequest{ + Name: "mackms:label=test-p256;policy=user-presence", + }}, require.Nil, assert.Error}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -566,8 +569,14 @@ func Test_parseURI(t *testing.T) { {"ok label uri simple", args{"mackms:the-label"}, &keyAttributes{label: "the-label", tag: DefaultTag, retry: true}, assert.NoError}, {"ok label empty tag", args{"mackms:label=the-label;tag="}, &keyAttributes{label: "the-label", tag: ""}, assert.NoError}, {"ok label empty tag no equal", args{"mackms:label=the-label;tag"}, &keyAttributes{label: "the-label", tag: ""}, assert.NoError}, + {"ok policy user-presence", args{"mackms:label=k;se=true;policy=user-presence"}, &keyAttributes{label: "k", tag: DefaultTag, retry: true, useSecureEnclave: true, authPolicy: authPolicyUserPresence}, assert.NoError}, + {"ok policy user-verification with cache", args{"mackms:label=k;se=true;policy=user-verification;cache=60s"}, &keyAttributes{label: "k", tag: DefaultTag, retry: true, useSecureEnclave: true, authPolicy: authPolicyUserVerification, authReuse: 60 * time.Second}, assert.NoError}, + {"ok policy none with reason", args{"mackms:label=k;policy=none;reason=hello"}, &keyAttributes{label: "k", tag: DefaultTag, retry: true, authReason: "hello"}, assert.NoError}, {"fail parse", args{"mackms:%label=the-label"}, nil, assert.Error}, {"fail missing label", args{"mackms:hash=0102abcd"}, nil, assert.Error}, + {"fail unknown policy", args{"mackms:label=k;se=true;policy=bogus"}, nil, assert.Error}, + {"fail bad cache", args{"mackms:label=k;se=true;policy=user-presence;cache=nope"}, nil, assert.Error}, + {"fail negative cache", args{"mackms:label=k;se=true;policy=user-presence;cache=-1s"}, nil, assert.Error}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/kms/mackms/signer.go b/kms/mackms/signer.go index 69acb772..4f5d792f 100644 --- a/kms/mackms/signer.go +++ b/kms/mackms/signer.go @@ -49,13 +49,25 @@ func (s *Signer) Public() crypto.PublicKey { // signature will be either a PKCS #1 v1.5 or PSS signature (as indicated by // opts). For an ECDSA key, it will be a DER-serialized, ASN.1 signature // structure. +// +// If the underlying key was created with an authorization policy +// (policy=user-presence or policy=user-verification), the Secure Enclave +// will prompt the user before completing this call. The configured reason +// and cache duration are surfaced via LAContext. Re-prompts within the +// cache window are suppressed by macOS. func (s *Signer) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) { algo, err := getSecKeyAlgorithm(s.pub, opts) if err != nil { return nil, fmt.Errorf("mackms Sign failed: %w", err) } - key, err := getPrivateKey(s.keyAttributes) + var laCtx *security.LAContextRef + if s.keyAttributes.authPolicy != authPolicyNone { + laCtx = security.NewLAContext(s.keyAttributes.authReason, s.keyAttributes.authReuse) + defer laCtx.Release() + } + + key, err := getPrivateKey(s.keyAttributes, laCtx) if err != nil { return nil, fmt.Errorf("mackms Sign failed: %w", err) } @@ -135,7 +147,13 @@ type ECDH struct { // Notice: This API is EXPERIMENTAL and may be changed or removed in a later // release. func (e *ECDH) ECDH(pub *ecdh.PublicKey) ([]byte, error) { - key, err := getPrivateKey(e.Signer.keyAttributes) + var laCtx *security.LAContextRef + if e.Signer.keyAttributes.authPolicy != authPolicyNone { + laCtx = security.NewLAContext(e.Signer.keyAttributes.authReason, e.Signer.keyAttributes.authReuse) + defer laCtx.Release() + } + + key, err := getPrivateKey(e.Signer.keyAttributes, laCtx) if err != nil { return nil, fmt.Errorf("mackms ECDH failed: %w", err) }