diff --git a/lib/subca/dn.go b/lib/subca/dn.go new file mode 100644 index 00000000000..c362f225335 --- /dev/null +++ b/lib/subca/dn.go @@ -0,0 +1,102 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package subca + +import ( + "crypto/x509/pkix" + + "github.com/gravitational/trace" + + subcav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/subca/v1" +) + +// RDNSequenceToDistinguishedNameProto converts a pkix.Name to a +// subcav1.DistinguishedName proto. +// +// All ATV values must be strings. Always returns non-nil on success. +func RDNSequenceToDistinguishedNameProto(rdns pkix.RDNSequence) (*subcav1.DistinguishedName, error) { + if len(rdns) == 0 { + return &subcav1.DistinguishedName{}, nil + } + + // Usually it's one item per nameset. OK if not. + estimatedLen := len(rdns) + + dn := &subcav1.DistinguishedName{ + Names: make([]*subcav1.AttributeTypeAndValue, 0, estimatedLen), + } + for i, nameSet := range rdns { + for j, atv := range nameSet { + oid := make([]int32, len(atv.Type)) + for i, x := range atv.Type { + // OID components are guaranteed to fit in a 32 bit integer as checked + // by encoding/asn1.parseBase128Int. + // https://cs.opensource.google/go/go/+/refs/tags/go1.26.2:src/encoding/asn1/asn1.go;l=323 + oid[i] = int32(x) + } + val, ok := atv.Value.(string) + if !ok { + return nil, trace.BadParameter( + "rdns[%d][%d]: ATV value is not a string: %q=%v (%T)", + i, j, + atv.Type, atv.Value, atv.Value, + ) + } + dn.Names = append(dn.Names, &subcav1.AttributeTypeAndValue{ + Oid: oid, + Value: &val, + }) + } + } + return dn, nil +} + +// DistinguishedNameProtoToRDNSequence converts a subcav1.DistinguishedName +// proto to an RDNSequence. +// +// DistinguishedName protos are considered user-input, therefore this method +// applies more strict validation than its sibling +// [RDNSequenceToDistinguishedNameProto]. +// +// A nil or empty DistinguishedName is considered invalid. +func DistinguishedNameProtoToRDNSequence(dn *subcav1.DistinguishedName) (pkix.RDNSequence, error) { + if dn == nil || len(dn.Names) == 0 { + return nil, trace.BadParameter("empty distinguished name") + } + + rdns := make(pkix.RDNSequence, 0, len(dn.Names)) + for i, atv := range dn.Names { + switch { + case len(atv.GetOid()) == 0: + return nil, trace.BadParameter("names[%d]: empty OID", i) + case atv.Value == nil: + return nil, trace.BadParameter("names[%d]: nil Value", i) + } + + oid := make([]int, len(atv.Oid)) + for j, x := range atv.Oid { + oid[j] = int(x) + } + rdns = append(rdns, pkix.RelativeDistinguishedNameSET{ + pkix.AttributeTypeAndValue{ + Type: oid, + Value: *atv.Value, + }, + }) + } + return rdns, nil +} diff --git a/lib/subca/dn_test.go b/lib/subca/dn_test.go new file mode 100644 index 00000000000..7bd7bdf2bdd --- /dev/null +++ b/lib/subca/dn_test.go @@ -0,0 +1,138 @@ +// Teleport +// Copyright (C) 2026 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package subca_test + +import ( + "crypto/x509/pkix" + "encoding/asn1" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + + subcav1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/subca/v1" + "github.com/gravitational/teleport/lib/subca" +) + +func TestDistinguishedName_roundtrip(t *testing.T) { + t.Parallel() + + want := (pkix.Name{ + Organization: []string{"Llama"}, + CommonName: "Llamo", + ExtraNames: []pkix.AttributeTypeAndValue{ + {Type: asn1.ObjectIdentifier{1, 2, 3, 4}, Value: "alpaca"}, + }, + }).ToRDNSequence() + + dn, err := subca.RDNSequenceToDistinguishedNameProto(want) + require.NoError(t, err, "RDNSequenceToDistinguishedNameProto errored") + + got, err := subca.DistinguishedNameProtoToRDNSequence(dn) + require.NoError(t, err, "DistinguishedNameProtoToRDNSequence errored") + assert.Equal(t, want, got, "DistinguishedName roundtrip mismatch") +} + +func TestRDNSequenceToDistinguishedNameProto(t *testing.T) { + t.Parallel() + + // Success tested by TestDistinguishedName_roundtrip. + + t.Run("nil", func(t *testing.T) { + t.Parallel() + got, err := subca.RDNSequenceToDistinguishedNameProto(nil) + require.NoError(t, err) + want := &subcav1.DistinguishedName{} + if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" { + t.Errorf("DistinguishedName mismatch (-want +got)\n%s", diff) + } + }) + + t.Run("non-string value", func(t *testing.T) { + t.Parallel() + + rdns := (pkix.Name{ + ExtraNames: []pkix.AttributeTypeAndValue{ + {Type: asn1.ObjectIdentifier{1, 2, 3, 4}, Value: "ok"}, // OK + {Type: asn1.ObjectIdentifier{1, 2, 3, 5}, Value: 42}, // NOK + }, + }).ToRDNSequence() + + _, err := subca.RDNSequenceToDistinguishedNameProto(rdns) + assert.ErrorContains(t, err, "not a string", "error mismatch") + }) +} + +func TestDistinguishedNameProtoToRDNSequence(t *testing.T) { + t.Parallel() + + // Success tested by TestDistinguishedName_roundtrip. + + exampleValue := "alpaca" + + tests := []struct { + name string + dn *subcav1.DistinguishedName + wantErr string + }{ + { + name: "nil", + wantErr: "empty distinguished name", + }, + { + name: "empty", + dn: &subcav1.DistinguishedName{}, + wantErr: "empty distinguished name", + }, + { + name: "ATV nil", + dn: &subcav1.DistinguishedName{ + Names: []*subcav1.AttributeTypeAndValue{ + nil, + }, + }, + wantErr: "empty OID", + }, + { + name: "ATV.OID empty", + dn: &subcav1.DistinguishedName{ + Names: []*subcav1.AttributeTypeAndValue{ + {Oid: []int32{}, Value: &exampleValue}, + }, + }, + wantErr: "empty OID", + }, + { + name: "ATV.Value nil", + dn: &subcav1.DistinguishedName{ + Names: []*subcav1.AttributeTypeAndValue{ + {Oid: []int32{1, 2, 3, 4}}, + }, + }, + wantErr: "nil Value", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := subca.DistinguishedNameProtoToRDNSequence(test.dn) + assert.ErrorContains(t, err, test.wantErr, "error mismatch") + }) + } +}