Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions internal/darwin/security/lacontext_darwin.go
Original file line number Diff line number Diff line change
@@ -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 <CoreFoundation/CoreFoundation.h>
#import <Foundation/Foundation.h>
#import <LocalAuthentication/LocalAuthentication.h>

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)
}
1 change: 1 addition & 0 deletions internal/darwin/security/security_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
Expand Down
113 changes: 104 additions & 9 deletions kms/mackms/mackms.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,33 @@ 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
hash []byte
retry bool
useSecureEnclave bool
useBiometrics bool
authPolicy authPolicy
authReuse time.Duration
authReason string
sigAlgorithm apiv1.SignatureAlgorithm
keySize int
}
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -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))
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions kms/mackms/mackms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading