@blamejs/pki 0.1.13 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@ All notable changes to `@blamejs/pki` are documented here. The format
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this
5
5
  project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## v0.1.14 — 2026-07-06
8
+
9
+ An RFC 5755 attribute-certificate parser joins the pki.schema family.
10
+
11
+ ### Added
12
+
13
+ - pki.schema.attrcert — an RFC 5755 attribute-certificate parser. pki.schema.attrcert.parse turns a DER Buffer or PEM into a structured v2 attribute certificate ({ version, holder, issuer, signatureAlgorithm, serialNumber, serialNumberHex, validity, attributes, issuerUniqueID, extensions, tbsBytes, signatureValue }). The holder (baseCertificateID / entityName / objectDigestInfo) and issuer (v1Form / v2Form) identities come back as validated GeneralNames; the validity window is real Dates; the privilege attributes (id-at-role, id-aca-group, id-at-clearance, and any others) resolve by name where the registry knows them. The outer-equals-inner signatureAlgorithm agreement (RFC 5755 4.2.4), the positive-and-at-most-20-octet serialNumber (4.2.5), the GeneralizedTime-only validity (4.2.6), the non-empty unique-typed attribute list (4.2.7), and the digestedObjectType enumeration are all enforced fail-closed. pki.schema.parse detect-and-routes an attribute certificate; the obsolete v1 form is recognized and deferred with a precise attrcert/legacy-v1-not-supported.
14
+ - pki.schema.pkix gains a shared GeneralNames validator that the attribute-certificate parser composes for its four GeneralNames-bearing fields — validating every element as a well-formed GeneralName (rejecting a bad tag, a wrong primitive/constructed form, a non-IA5 string, or a mis-sized iPAddress) rather than surfacing the sequence as opaque bytes. It handles both a bare universal SEQUENCE OF GeneralName and a context-tagged IMPLICIT GeneralNames.
15
+ - The OID registry gains the RFC 5755 attribute-certificate object identifiers: the id-aca attribute-type family (authenticationInfo / accessIdentity / chargingIdentity / group), id-at-role and id-at-clearance, the id-ce-targetInformation and id-ce-noRevAvail extensions, and the id-pe-ac-auditIdentity / id-pe-aaControls / id-pe-ac-proxying private extensions — so a parsed attribute certificate's attributes and extensions resolve by name.
16
+
7
17
  ## v0.1.13 — 2026-07-05
8
18
 
9
19
  An RFC 3161 timestamp parser joins the pki.schema family.
package/README.md CHANGED
@@ -198,6 +198,7 @@ is callable today; nothing below is a stub.
198
198
  | `pki.schema.cms` | Parse DER / PEM CMS SignedData per RFC 5652 — encapsulated content, signer infos, raw signed-attribute bytes for external verification, certificates / CRLs kept raw, fail-closed; non-SignedData content types recognized (not decoded) — `parse`, `pemDecode`, `pemEncode` |
199
199
  | `pki.schema.ocsp` | Parse DER / PEM OCSP requests and responses per RFC 6960 — per-certificate status (good / revoked / unknown), responder identity, raw tbs bytes for external verification, certificates kept raw, fail-closed; non-basic response types recognized (not decoded) — `parseRequest`, `parseResponse`, `pemDecode` |
200
200
  | `pki.schema.tsp` | Parse DER / PEM RFC 3161 timestamp responses and tokens — the TSTInfo payload (imprint, genTime with sub-second precision, serial, nonce, accuracy), the status-to-token coupling, and the token wrapper composed over CMS with the single-signer rule, fail-closed — `parse`, `parseTstInfo`, `parseToken`, `pemDecode` |
201
+ | `pki.schema.attrcert` | Parse DER / PEM RFC 5755 attribute certificates — the holder and issuer identities (validated GeneralNames), the validity window (real `Date`s), the privilege attributes (role / group / clearance) and extensions, with the raw signed region for a verifier; the obsolete v1 form is recognized and deferred, fail-closed — `parse`, `pemDecode` |
201
202
  | `pki.schema.engine` | The declarative ASN.1 structure-schema engine every format parser composes — `walk` plus the schema combinators |
202
203
  | `pki.C` / `pki.constants` | Version-stable constants — functional scale helpers (`C.TIME.*`, `C.BYTES.*`), codec `LIMITS`, `version` |
203
204
  | `pki.errors` | The `PkiError` taxonomy — `defineClass` plus `ConstantsError` / `Asn1Error` / `OidError` / `PemError` / `CertificateError` / `CrlError` / `CsrError` / `Pkcs8Error` / `CmsError` / `OcspError` / `TspError`, each carrying a stable `code` in `domain/reason` form |
@@ -238,9 +239,12 @@ plus a documentation comment block, not new parse logic.
238
239
  │ pki.schema.parse — inspect the DER root, route to the matching sibling │
239
240
  └─────────────────────────────────────────────────────────────────────────┘
240
241
 
241
- ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ Format
242
- x509 │ │ crl │ │ csr │ │pkcs8 │ │ cms │ │ ocsp │ │ tsp │ parsers
243
- └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ (siblings)
242
+ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ Format
243
+ x509 │ │ crl │ │ csr │ │ pkcs8 │ parsers
244
+ └────────┘ └────────┘ └────────┘ └────────┘ (siblings)
245
+ ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐
246
+ │ cms │ │ ocsp │ │ tsp │ │attrcert│
247
+ └────────┘ └────────┘ └────────┘ └────────┘
244
248
  │ every sibling composes ↓
245
249
  ┌─ Shared structure ──────────────────────────────────────────────────────┐
246
250
  │ pki.schema.engine — walk + combinators (positional reads, tag order, │
@@ -272,7 +276,7 @@ bounded version reader, and the single coerce → decode → walk parse-entry th
272
276
  every format's `parse` is bound to. Because input coercion, the PEM size cap, and
273
277
  the DER-decode wrapping live here once, a format cannot diverge on a guard.
274
278
 
275
- **Format parsers.** `x509`, `crl`, `csr`, `pkcs8`, `cms`, `ocsp`, and `tsp` are siblings:
279
+ **Format parsers.** `x509`, `crl`, `csr`, `pkcs8`, `cms`, `ocsp`, `tsp`, and `attrcert` are siblings:
276
280
  each is a schema declaration composed from the shared pieces, emitting its own
277
281
  typed `domain/reason` error codes. `pki.schema.parse` inspects a decoded root and
278
282
  detect-and-routes to the matching sibling; the detectors are mutually exclusive by
@@ -149,6 +149,12 @@ var OcspError = defineClass("OcspError", { withCause: true });
149
149
  // TSTInfo, or TimeStampResp. Carries the underlying leaf fault as `.cause`.
150
150
  var TspError = defineClass("TspError", { withCause: true });
151
151
 
152
+ // AttrCertError — a byte sequence that is not a well-formed X.509 Attribute
153
+ // Certificate (AttributeCertificate / AttributeCertificateInfo — RFC 5755 §4),
154
+ // or a recognized-and-deferred legacy AttributeCertificateV1. Carries the
155
+ // underlying leaf fault as `.cause`.
156
+ var AttrCertError = defineClass("AttrCertError", { withCause: true });
157
+
152
158
  module.exports = {
153
159
  PkiError: PkiError,
154
160
  defineClass: defineClass,
@@ -164,4 +170,5 @@ module.exports = {
164
170
  CmsError: CmsError,
165
171
  OcspError: OcspError,
166
172
  TspError: TspError,
173
+ AttrCertError: AttrCertError,
167
174
  };
package/lib/oid.js CHANGED
@@ -49,7 +49,9 @@ var FAMILIES = {
49
49
  attributeType: { base: [2, 5, 4], of: {
50
50
  commonName: 3, surname: 4, serialNumber: 5, countryName: 6,
51
51
  localityName: 7, stateOrProvinceName: 8, streetAddress: 9,
52
- organizationName: 10, organizationalUnitName: 11, title: 12, givenName: 42 } },
52
+ organizationName: 10, organizationalUnitName: 11, title: 12, givenName: 42,
53
+ // RFC 5755 §4.4 attribute-certificate attribute types.
54
+ clearance: 55, role: 72 } },
53
55
 
54
56
  // RFC 5280 certificate extensions.
55
57
  certExtension: { base: [2, 5, 29], of: {
@@ -60,10 +62,28 @@ var FAMILIES = {
60
62
  issuingDistributionPoint: 28, certificateIssuer: 29,
61
63
  nameConstraints: 30, cRLDistributionPoints: 31,
62
64
  certificatePolicies: 32, authorityKeyIdentifier: 35, extKeyUsage: 37,
63
- freshestCRL: 46 } },
64
-
65
- // PKIX private extensions (authorityInfoAccess et al).
66
- pkixAccess: { base: [1, 3, 6, 1, 5, 5, 7, 1], of: { authorityInfoAccess: 1 } },
65
+ freshestCRL: 46,
66
+ // RFC 5755 attribute-certificate extensions (§4.3.2, §4.3.6).
67
+ targetInformation: 55, noRevAvail: 56 } },
68
+
69
+ // PKIX private extensions on the id-pe arc (authorityInfoAccess et al, plus the
70
+ // RFC 5755 attribute-certificate id-pe extensions: ac-auditIdentity §4.3.1,
71
+ // aaControls §7.4, ac-proxying §7.2).
72
+ pkixAccess: { base: [1, 3, 6, 1, 5, 5, 7, 1], of: {
73
+ authorityInfoAccess: 1, acAuditIdentity: 4, aaControls: 6, acProxying: 10 } },
74
+
75
+ // id-aca — RFC 5755 attribute-certificate attribute types on the id-pkix 10 arc:
76
+ // authenticationInfo §4.4.1 .. group §4.4.4, plus encAttrs §7.1 (the encrypted-
77
+ // attribute wrapper, syntax ContentInfo). { id-aca 5 } is reserved.
78
+ idAca: { base: [1, 3, 6, 1, 5, 5, 7, 10], of: {
79
+ authenticationInfo: 1, accessIdentity: 2, chargingIdentity: 3, group: 4, encAttrs: 6 } },
80
+
81
+ // The legacy RFC 3281 id-at-clearance on the X.501 selected-attribute-types arc.
82
+ // RFC 5755 §4.4.6 says implementations MUST NOT OUTPUT this form but SHOULD ACCEPT
83
+ // it for decoding, so it is registered as an alias of "clearance". The canonical
84
+ // name -> OID reverse stays the RFC 5755 attributeType-arc 2.5.4.55 (registered
85
+ // first under attributeType, so it wins the reverse mapping).
86
+ selectedAttrType: { base: [2, 5, 1, 5], of: { clearance: 55 } },
67
87
 
68
88
  // PKIX Access Descriptor methods (id-ad, RFC 5280 §4.2.2.1/§4.2.2.2). id-ad-ocsp
69
89
  // is the arc the OCSP responder OIDs hang under; id-ad-caIssuers names the AIA
package/lib/schema-all.js CHANGED
@@ -39,6 +39,7 @@ var pkcs8 = require("./schema-pkcs8");
39
39
  var cms = require("./schema-cms");
40
40
  var ocsp = require("./schema-ocsp");
41
41
  var tsp = require("./schema-tsp");
42
+ var attrcert = require("./schema-attrcert");
42
43
  var frameworkError = require("./framework-error");
43
44
 
44
45
  var SchemaError = frameworkError.SchemaError;
@@ -126,6 +127,27 @@ var FORMATS = [
126
127
  detect: csr.matches,
127
128
  parse: function (input) { return csr.parse(input); },
128
129
  },
130
+ {
131
+ // AttributeCertificate ::= SEQUENCE { acinfo, signatureAlgorithm, signatureValue }
132
+ // — the signed-envelope trio, distinguished from a certificate / CRL by an acinfo
133
+ // that LEADS WITH a bare INTEGER version and carries a 2-GeneralizedTime validity
134
+ // at children[5]. Registered ahead of crl / x509 (most-specific-first): a legacy v1
135
+ // AC would otherwise satisfy x509.matches's Validity probe (RFC 5755 §4.1).
136
+ name: "attrcert",
137
+ module: attrcert,
138
+ detect: attrcert.matches,
139
+ parse: function (input) { return attrcert.parse(input); },
140
+ },
141
+ {
142
+ // AttributeCertificateV1 (X.509-1997, RFC 5652 §10.2) — the obsolete predecessor
143
+ // whose acInfo LEADS WITH the subject CHOICE (context [0]/[1]). Recognized and
144
+ // deferred with a precise attrcert/legacy-v1-not-supported rather than routed to a
145
+ // wrong format. children[1] == SEQUENCE keeps it disjoint from a v3 certificate.
146
+ name: "attrcert-v1",
147
+ module: attrcert,
148
+ detect: attrcert.matchesV1,
149
+ parse: function (input) { return attrcert.parseV1(input); },
150
+ },
129
151
  {
130
152
  // CertificateList ::= SEQUENCE { tbsCertList, signatureAlgorithm,
131
153
  // signatureValue } — the same outer shape as a certificate, distinguished
@@ -157,7 +179,7 @@ var FORMATS = [
157
179
  * The names of every registered format, in detection order.
158
180
  *
159
181
  * @example
160
- * pki.schema.all(); // → ["cms", "tsp", "ocsp-request", "ocsp-response", "pkcs8", "csr", "crl", "x509"]
182
+ * pki.schema.all(); // → ["cms", "tsp", "ocsp-request", "ocsp-response", "pkcs8", "csr", "attrcert", "attrcert-v1", "crl", "x509"]
161
183
  */
162
184
  function all() { return FORMATS.map(function (f) { return f.name; }); }
163
185
 
@@ -202,6 +224,7 @@ module.exports = {
202
224
  cms: { parse: cms.parse, pemDecode: cms.pemDecode, pemEncode: cms.pemEncode },
203
225
  ocsp: { parseRequest: ocsp.parseRequest, parseResponse: ocsp.parseResponse, pemDecode: ocsp.pemDecode },
204
226
  tsp: { parse: tsp.parse, parseTstInfo: tsp.parseTstInfo, parseToken: tsp.parseToken, pemDecode: tsp.pemDecode },
227
+ attrcert: { parse: attrcert.parse, pemDecode: attrcert.pemDecode },
205
228
  all: all,
206
229
  parse: parse,
207
230
  };
@@ -0,0 +1,383 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ // Copyright (c) blamejs contributors
3
+ "use strict";
4
+ /**
5
+ * @module pki.schema.attrcert
6
+ * @nav Schema
7
+ * @title Attribute Cert
8
+ * @order 175
9
+ * @slug attrcert
10
+ *
11
+ * @intro
12
+ * X.509 Attribute Certificate handling per RFC 5755. An attribute certificate
13
+ * binds a holder to a set of privilege attributes (role, group, clearance)
14
+ * without carrying a public key — the authorization counterpart to an identity
15
+ * certificate. `parse` decodes a v2 `AttributeCertificate` into its holder,
16
+ * issuer, validity window (real `Date`s), attributes, and extensions, reusing
17
+ * the shared signed-envelope so the raw `tbsBytes` (the exact signed region) and
18
+ * the `signatureValue` are surfaced for a downstream verifier.
19
+ *
20
+ * The holder and issuer identities are `GeneralName`s, validated on the way in
21
+ * (each alternative's form and content per RFC 5280 §4.2.1.6) and surfaced with
22
+ * their raw bytes; the profile MUSTs above structural scope (a single
23
+ * non-empty `directoryName` issuer, the holder-to-PKC binding, targeting and
24
+ * revocation) are verification-layer concerns. The obsolete X.509-1997
25
+ * `AttributeCertificateV1` is recognized and deferred, not parsed. DER-only,
26
+ * fail-closed.
27
+ *
28
+ * @card
29
+ * Parse DER / PEM RFC 5755 attribute certificates into holder, issuer,
30
+ * validity, attributes, and extensions — validated GeneralNames, raw verifier
31
+ * inputs, legacy v1 recognize-and-defer, fail-closed.
32
+ */
33
+
34
+ var asn1 = require("./asn1-der");
35
+ var schema = require("./schema-engine");
36
+ var pkix = require("./schema-pkix");
37
+ var oid = require("./oid");
38
+ var frameworkError = require("./framework-error");
39
+
40
+ var AttrCertError = frameworkError.AttrCertError;
41
+ var PemError = frameworkError.PemError;
42
+
43
+ var NS = pkix.makeNS("attrcert", AttrCertError, oid);
44
+
45
+ var TAGS = asn1.TAGS;
46
+
47
+ var ALGORITHM_IDENTIFIER = pkix.algorithmIdentifier(NS);
48
+ var EXTENSIONS = pkix.extensions(NS);
49
+
50
+ // AttCertVersion ::= INTEGER { v2(1) } — a bare, mandatory INTEGER (not the x509
51
+ // [0] EXPLICIT DEFAULT shape). The only legal value is v2, surfaced as 2.
52
+ var VERSION = pkix.versionReader(NS, { "1": 2 });
53
+
54
+ // ---- shared leaves ---------------------------------------------------
55
+
56
+ // AttCertValidityPeriod times are GeneralizedTime (RFC 5755 §4.2.6) — a UTCTime is
57
+ // rejected. Non-fractional YYYYMMDDHHMMSSZ is enforced by the codec.
58
+ var GENERALIZED_TIME = schema.decode(function (n, ctx) {
59
+ if (n.tagClass !== "universal" || n.tagNumber !== TAGS.GENERALIZED_TIME) {
60
+ throw ctx.E("attrcert/bad-time", "AttCertValidityPeriod times must be GeneralizedTime (RFC 5755 §4.2.6)");
61
+ }
62
+ return asn1.read.time(n);
63
+ });
64
+
65
+ // digestedObjectType ::= ENUMERATED { publicKey(0), publicKeyCert(1),
66
+ // otherObjectTypes(2) } (RFC 5755 §4.1) — an undefined value is rejected; a
67
+ // non-ENUMERATED tag fails at the codec (asn1/*).
68
+ var ODT_NAMES = { "0": "publicKey", "1": "publicKeyCert", "2": "otherObjectTypes" };
69
+ var DIGESTED_OBJECT_TYPE = schema.decode(function (n, ctx) {
70
+ var v = asn1.read.enumerated(n);
71
+ var k = v.toString();
72
+ if (!Object.prototype.hasOwnProperty.call(ODT_NAMES, k)) {
73
+ throw ctx.E("attrcert/bad-digested-object-type", "digestedObjectType " + k + " is not a defined value (RFC 5755 §4.1)");
74
+ }
75
+ // RFC 5755 §7.3 — a conformant AC MUST NOT use otherObjectTypes(2); the digested
76
+ // object is limited to a public key or a public-key certificate, so reject it
77
+ // fail-closed rather than parse a digest whose object type is unidentifiable.
78
+ if (k === "2") {
79
+ throw ctx.E("attrcert/bad-digested-object-type", "otherObjectTypes(2) MUST NOT be used (RFC 5755 §7.3)");
80
+ }
81
+ return { code: Number(v), name: ODT_NAMES[k] };
82
+ });
83
+
84
+ // ---- IssuerSerial / ObjectDigestInfo ---------------------------------
85
+
86
+ // IssuerSerial ::= SEQUENCE { issuer GeneralNames, serial CertificateSerialNumber,
87
+ // issuerUID UniqueIdentifier OPTIONAL } (RFC 5755 §4.1). Reached as an IMPLICIT
88
+ // [n] node (Holder [0] / V2Form [0]) whose tag replaces the SEQUENCE tag, so the
89
+ // shape assertion is "constructed" (the tag is pinned by the enclosing trailing);
90
+ // a context [n] PRIMITIVE node has no children and fails closed here.
91
+ var ISSUER_SERIAL = schema.seq([
92
+ schema.field("issuer", pkix.generalNames(NS, { code: "attrcert/bad-issuer-serial" })),
93
+ schema.field("serial", schema.integerLeaf()),
94
+ schema.optional("issuerUID", schema.bitString(), { whenUniversal: [TAGS.BIT_STRING] }),
95
+ ], {
96
+ assert: "constructed", code: "attrcert/bad-issuer-serial", what: "IssuerSerial",
97
+ build: function (m) {
98
+ return {
99
+ issuer: m.fields.issuer.value.result,
100
+ serial: m.fields.serial.value,
101
+ serialHex: m.fields.serial.node.content.toString("hex"),
102
+ issuerUID: m.fields.issuerUID.present ? m.fields.issuerUID.value : null,
103
+ };
104
+ },
105
+ });
106
+
107
+ // ObjectDigestInfo ::= SEQUENCE { digestedObjectType ENUMERATED, otherObjectTypeID
108
+ // OBJECT IDENTIFIER OPTIONAL, digestAlgorithm AlgorithmIdentifier, objectDigest BIT
109
+ // STRING } (RFC 5755 §4.1). otherObjectTypeID is present only for otherObjectTypes(2).
110
+ var OBJECT_DIGEST_INFO = schema.seq([
111
+ schema.field("digestedObjectType", DIGESTED_OBJECT_TYPE),
112
+ schema.optional("otherObjectTypeID", schema.oidLeaf(), { whenUniversal: [TAGS.OBJECT_IDENTIFIER] }),
113
+ schema.field("digestAlgorithm", ALGORITHM_IDENTIFIER),
114
+ schema.field("objectDigest", schema.bitString()),
115
+ ], {
116
+ assert: "constructed", code: "attrcert/bad-object-digest-info", what: "ObjectDigestInfo",
117
+ build: function (m, ctx) {
118
+ var t = m.fields.digestedObjectType.value;
119
+ // otherObjectTypeID identifies a non-certificate object type, meaningful only with
120
+ // otherObjectTypes(2) — which RFC 5755 §7.3 forbids (rejected at the leaf above) —
121
+ // so a present otherObjectTypeID is never valid here and is rejected fail-closed.
122
+ if (m.fields.otherObjectTypeID.present) {
123
+ throw ctx.E("attrcert/bad-object-digest-info", "otherObjectTypeID is only valid with otherObjectTypes(2), which RFC 5755 §7.3 forbids");
124
+ }
125
+ return {
126
+ digestedObjectType: t,
127
+ otherObjectTypeID: null,
128
+ digestAlgorithm: m.fields.digestAlgorithm.value.result,
129
+ objectDigest: m.fields.objectDigest.value,
130
+ };
131
+ },
132
+ });
133
+
134
+ // ---- Holder / AttCertIssuer / V2Form ---------------------------------
135
+
136
+ // Holder ::= SEQUENCE { baseCertificateID [0] IMPLICIT IssuerSerial OPTIONAL,
137
+ // entityName [1] IMPLICIT GeneralNames OPTIONAL, objectDigestInfo [2] IMPLICIT
138
+ // ObjectDigestInfo OPTIONAL } (RFC 5755 §4.1). All three are IMPLICIT + OPTIONAL;
139
+ // the profile RECOMMENDS exactly one, but the parser surfaces all three (null when
140
+ // absent) and does not enforce the cardinality.
141
+ var HOLDER = schema.seq([
142
+ schema.trailing([
143
+ { tag: 0, name: "baseCertificateID", schema: ISSUER_SERIAL },
144
+ { tag: 1, name: "entityName", schema: pkix.generalNames(NS, { implicitTag: 1, code: "attrcert/bad-entity-name" }) },
145
+ { tag: 2, name: "objectDigestInfo", schema: OBJECT_DIGEST_INFO },
146
+ ], { minTag: 0, maxTag: 2, unexpectedCode: "attrcert/bad-holder", orderCode: "attrcert/bad-holder" }),
147
+ ], {
148
+ assert: "sequence", code: "attrcert/bad-holder", what: "Holder",
149
+ build: function (m) {
150
+ return {
151
+ baseCertificateID: m.fields.baseCertificateID.present ? m.fields.baseCertificateID.value.result : null,
152
+ entityName: m.fields.entityName.present ? m.fields.entityName.value.result : null,
153
+ objectDigestInfo: m.fields.objectDigestInfo.present ? m.fields.objectDigestInfo.value.result : null,
154
+ };
155
+ },
156
+ });
157
+
158
+ // V2Form ::= SEQUENCE { issuerName GeneralNames OPTIONAL, baseCertificateID [0]
159
+ // IMPLICIT IssuerSerial OPTIONAL, objectDigestInfo [1] IMPLICIT ObjectDigestInfo
160
+ // OPTIONAL } (RFC 5755 §4.1). Reached via the AttCertIssuer CHOICE on a context [0]
161
+ // node, so the shape is "constructed".
162
+ var V2_FORM = schema.seq([
163
+ schema.optional("issuerName", pkix.generalNames(NS, { code: "attrcert/bad-issuer-name" }), { whenUniversal: [TAGS.SEQUENCE] }),
164
+ schema.trailing([
165
+ { tag: 0, name: "baseCertificateID", schema: ISSUER_SERIAL },
166
+ { tag: 1, name: "objectDigestInfo", schema: OBJECT_DIGEST_INFO },
167
+ ], { minTag: 0, maxTag: 1, unexpectedCode: "attrcert/bad-v2form", orderCode: "attrcert/bad-v2form" }),
168
+ ], {
169
+ assert: "constructed", code: "attrcert/bad-v2form", what: "V2Form",
170
+ build: function (m) {
171
+ return {
172
+ issuerName: m.fields.issuerName.present ? m.fields.issuerName.value.result : null,
173
+ baseCertificateID: m.fields.baseCertificateID.present ? m.fields.baseCertificateID.value.result : null,
174
+ objectDigestInfo: m.fields.objectDigestInfo.present ? m.fields.objectDigestInfo.value.result : null,
175
+ };
176
+ },
177
+ });
178
+
179
+ // AttCertIssuer ::= CHOICE { v1Form GeneralNames, v2Form [0] IMPLICIT V2Form } (RFC
180
+ // 5755 §4.1). A universal SEQUENCE is the (obsolete but structurally legal) v1Form;
181
+ // a context [0] is v2Form. A conforming AC uses v2Form; the parser surfaces which.
182
+ var ATT_CERT_ISSUER = schema.choice([
183
+ { when: { tagClass: "universal", tagNumber: TAGS.SEQUENCE }, schema: pkix.generalNames(NS, { code: "attrcert/bad-issuer" }) },
184
+ { when: { tagClass: "context", tagNumber: 0 }, schema: V2_FORM },
185
+ ], { code: "attrcert/bad-issuer" });
186
+
187
+ // ---- validity + attributes -------------------------------------------
188
+
189
+ // AttCertValidityPeriod ::= SEQUENCE { notBeforeTime GeneralizedTime, notAfterTime
190
+ // GeneralizedTime } (RFC 5755 §4.2.6).
191
+ var VALIDITY = schema.seq([
192
+ schema.field("notBeforeTime", GENERALIZED_TIME),
193
+ schema.field("notAfterTime", GENERALIZED_TIME),
194
+ ], {
195
+ assert: "sequence", arity: { exact: 2 }, code: "attrcert/bad-validity", what: "AttCertValidityPeriod",
196
+ build: function (m) { return { notBeforeTime: m.fields.notBeforeTime.value, notAfterTime: m.fields.notAfterTime.value }; },
197
+ });
198
+
199
+ // attributes ::= SEQUENCE OF Attribute (RFC 5755 §4.2.7) — MUST be non-empty and
200
+ // each AttributeType OID unique. Order-preserving (SEQUENCE OF, not SET OF).
201
+ var ATTRIBUTES = schema.seqOf(pkix.attribute(NS), {
202
+ assert: "sequence", min: 1, code: "attrcert/bad-attributes", what: "attributes",
203
+ unique: function (it) { return it.value.result.type; }, dupCode: "attrcert/duplicate-attribute",
204
+ build: function (m) { return m.items.map(function (it) { return it.value.result; }); },
205
+ });
206
+
207
+ // ---- AttributeCertificateInfo / AttributeCertificate -----------------
208
+
209
+ // AttributeCertificateInfo ::= SEQUENCE { version, holder, issuer, signature
210
+ // AlgorithmIdentifier, serialNumber, attrCertValidityPeriod, attributes,
211
+ // issuerUniqueID OPTIONAL, extensions OPTIONAL } (RFC 5755 §4.1). The tbs body the
212
+ // signed envelope wraps; the cross-field invariants live in the envelope build.
213
+ var ACINFO = schema.seq([
214
+ schema.field("version", VERSION),
215
+ schema.field("holder", HOLDER),
216
+ schema.field("issuer", ATT_CERT_ISSUER),
217
+ schema.field("signature", ALGORITHM_IDENTIFIER),
218
+ schema.field("serialNumber", schema.integerLeaf()),
219
+ schema.field("attrCertValidityPeriod", VALIDITY),
220
+ schema.field("attributes", ATTRIBUTES),
221
+ schema.optional("issuerUniqueID", schema.bitString(), { whenUniversal: [TAGS.BIT_STRING] }),
222
+ schema.optional("extensions", EXTENSIONS, { whenUniversal: [TAGS.SEQUENCE] }),
223
+ ], { assert: "sequence", code: "attrcert/bad-acinfo", what: "AttributeCertificateInfo" });
224
+
225
+ // AttributeCertificate ::= SEQUENCE { acinfo, signatureAlgorithm, signatureValue
226
+ // BIT STRING } — the shared SIGNED envelope (RFC 5755 §4.1). The AC-specific
227
+ // invariants (§4.2.4 sig-alg agreement, §4.2.5 positive-and-<=20-octet serialNumber)
228
+ // run in the build; the envelope owns the SEQUENCE-of-3 shape + signature extraction.
229
+ var ATTRIBUTE_CERTIFICATE = pkix.signedEnvelope(NS, ACINFO, {
230
+ code: "attrcert/not-an-attribute-certificate", what: "AttributeCertificate",
231
+ build: function (e, ctx) {
232
+ var acinfo = e.tbsMatch;
233
+
234
+ // RFC 5755 §4.2.4 — the outer signatureAlgorithm MUST equal acinfo.signature.
235
+ if (!e.outerSignatureAlgorithmBytes.equals(acinfo.fields.signature.node.bytes)) {
236
+ throw ctx.E("attrcert/bad-signature-algorithm", "signatureAlgorithm must match AttributeCertificateInfo.signature (RFC 5755 §4.2.4)");
237
+ }
238
+
239
+ // RFC 5755 §4.2.5 — serialNumber MUST be a positive INTEGER of at most 20 content
240
+ // octets. Positive excludes both a negative (DER sign bit set) and zero (a minimal
241
+ // INTEGER 0 is a single 0x00 octet — the codec already rejected any non-minimal
242
+ // zero). Surface it lossless (BigInt) + as hex.
243
+ var serialNode = acinfo.fields.serialNumber.node;
244
+ var sc = serialNode.content;
245
+ if (sc.length === 0 || (sc[0] & 0x80) !== 0 || (sc.length === 1 && sc[0] === 0)) {
246
+ throw ctx.E("attrcert/bad-serial-number", "serialNumber must be a positive INTEGER (RFC 5755 §4.2.5)");
247
+ }
248
+ if (sc.length > 20) {
249
+ throw ctx.E("attrcert/bad-serial-number", "serialNumber must not exceed 20 content octets (RFC 5755 §4.2.5)");
250
+ }
251
+
252
+ // AttCertIssuer is a CHOICE — the chosen arm is read off the issuer node's tag
253
+ // (a universal SEQUENCE is v1Form; a context [0] is v2Form).
254
+ var issuerNode = acinfo.fields.issuer.node;
255
+ var issuerVal = acinfo.fields.issuer.value;
256
+ var issuer = issuerNode.tagClass === "context"
257
+ ? { form: "v2Form", v2Form: issuerVal.result, v1Form: null }
258
+ : { form: "v1Form", v1Form: issuerVal.result, v2Form: null };
259
+
260
+ var extField = acinfo.fields.extensions;
261
+ return {
262
+ version: acinfo.fields.version.value, // 2
263
+ holder: acinfo.fields.holder.value.result,
264
+ issuer: issuer,
265
+ signatureAlgorithm: e.signatureAlgorithm,
266
+ tbsSignatureAlgorithm: acinfo.fields.signature.value.result,
267
+ serialNumber: acinfo.fields.serialNumber.value,
268
+ serialNumberHex: sc.toString("hex"),
269
+ validity: acinfo.fields.attrCertValidityPeriod.value.result,
270
+ attributes: acinfo.fields.attributes.value.result,
271
+ issuerUniqueID: acinfo.fields.issuerUniqueID.present ? acinfo.fields.issuerUniqueID.value : null,
272
+ extensions: (extField && extField.present) ? extField.value.result : [],
273
+ tbsBytes: e.tbsBytes,
274
+ signatureValue: e.signatureValue,
275
+ };
276
+ },
277
+ });
278
+
279
+ // ---- parse -----------------------------------------------------------
280
+
281
+ /**
282
+ * @primitive pki.schema.attrcert.parse
283
+ * @signature pki.schema.attrcert.parse(input) -> attributeCertificate
284
+ * @since 0.1.14
285
+ * @status experimental
286
+ * @spec RFC 5755, X.509
287
+ * @related pki.schema.parse, pki.schema.x509.parse
288
+ *
289
+ * Parse a DER `Buffer` or a PEM string/Buffer into a structured v2 attribute
290
+ * certificate: `{ version, holder, issuer, signatureAlgorithm, serialNumber,
291
+ * serialNumberHex, validity, attributes, issuerUniqueID, extensions, tbsBytes,
292
+ * signatureValue }`. The holder / issuer identities come back as validated
293
+ * `GeneralName`s (each element `{ bytes, tagClass, tagNumber }`); the validity
294
+ * window is real `Date`s; `tbsBytes` is the exact signed byte range for a verifier.
295
+ *
296
+ * Throws `AttrCertError` when the bytes are not a well-formed attribute certificate
297
+ * (an obsolete `AttributeCertificateV1` throws `attrcert/legacy-v1-not-supported`)
298
+ * and `Asn1Error` when the underlying DER is malformed.
299
+ *
300
+ * @example
301
+ * var ac = pki.schema.attrcert.parse(der);
302
+ * ac.attributes[0].name; // "role"
303
+ * ac.validity.notAfterTime;// Date
304
+ */
305
+ var PARSE_OPTS = { pemLabel: null, PemError: PemError, ErrorClass: AttrCertError, prefix: "attrcert", what: "attribute certificate" };
306
+
307
+ function _legacyV1Error() {
308
+ return new AttrCertError("attrcert/legacy-v1-not-supported", "AttributeCertificateV1 (X.509-1997) is obsolete and not parsed by this build (RFC 5755 §1)");
309
+ }
310
+
311
+ function parse(input) {
312
+ var root = pkix.decodeRoot(pkix.coerceToDer(input, PARSE_OPTS), PARSE_OPTS);
313
+ // A well-formed legacy AttributeCertificateV1 gets the advertised, stable
314
+ // attrcert/legacy-v1-not-supported on the DIRECT path too — not a low-level asn1/*
315
+ // tag error from attempting the v2 walk — so a direct caller of this entry sees the
316
+ // same error family for the recognized-but-deferred form as the orchestrator does.
317
+ if (matchesV1(root)) throw _legacyV1Error();
318
+ return schema.walk(ATTRIBUTE_CERTIFICATE, root, NS).result;
319
+ }
320
+
321
+ // The obsolete X.509-1997 AttributeCertificateV1 (RFC 5652 §10.2) is recognized and
322
+ // deferred: decode the envelope (so a malformed input still fails as bad DER), then
323
+ // throw the precise diagnostic rather than routing it to a wrong format.
324
+ function parseV1(input) {
325
+ pkix.decodeRoot(pkix.coerceToDer(input, PARSE_OPTS), PARSE_OPTS);
326
+ throw _legacyV1Error();
327
+ }
328
+
329
+ /**
330
+ * @primitive pki.schema.attrcert.pemDecode
331
+ * @signature pki.schema.attrcert.pemDecode(text, label?) -> Buffer
332
+ * @since 0.1.14
333
+ * @status experimental
334
+ * @spec RFC 7468, RFC 5755
335
+ * @related pki.schema.attrcert.parse
336
+ *
337
+ * Extract the DER bytes from a PEM block (OpenSSL emits
338
+ * `-----BEGIN ATTRIBUTE CERTIFICATE-----`; the first block is taken unless `label`
339
+ * is given). Throws `PemError` on a missing envelope or a non-base64 body.
340
+ *
341
+ * @example
342
+ * var der = pki.schema.attrcert.pemDecode(pemText);
343
+ */
344
+ function pemDecode(text, label) { return pkix.pemDecode(text, label || null, PemError); }
345
+
346
+ // matches(root): a v2 AttributeCertificate is the signed-envelope trio whose acinfo
347
+ // LEADS WITH a bare universal INTEGER version (== v2 = 1, NOT the x509 [0] EXPLICIT
348
+ // wrapper) and whose attrCertValidityPeriod at children[5] is a SEQUENCE of exactly
349
+ // two GeneralizedTime (RFC 5755 §4.2.6 forbids UTCTime) — a marker no cert / CRL /
350
+ // CSR presents at that position. Disjoint from matchesV1 by the children[0] tag class.
351
+ function matches(root) {
352
+ var acinfo = pkix.signedEnvelopeTbs(root);
353
+ if (!acinfo) return false;
354
+ var k = acinfo.children;
355
+ if (!k || k.length < 7) return false;
356
+ if (!(k[0].tagClass === "universal" && k[0].tagNumber === TAGS.INTEGER)) return false;
357
+ if (!(k[1].tagClass === "universal" && k[1].tagNumber === TAGS.SEQUENCE && k[1].children)) return false;
358
+ var v = k[5];
359
+ if (!(v && v.tagClass === "universal" && v.tagNumber === TAGS.SEQUENCE && v.children && v.children.length === 2)) return false;
360
+ return v.children.every(function (t) { return t.tagClass === "universal" && t.tagNumber === TAGS.GENERALIZED_TIME; });
361
+ }
362
+
363
+ // matchesV1(root): the obsolete AttributeCertificateV1 — its acInfo (version DEFAULT
364
+ // v1 OMITTED) LEADS WITH the subject CHOICE (a context [0]/[1]), and its children[1]
365
+ // is the issuer GeneralNames (a universal SEQUENCE). The children[1] == SEQUENCE test
366
+ // keeps it disjoint from a v3 certificate (whose [0] EXPLICIT version is followed by
367
+ // an INTEGER serialNumber), so v1-vs-cert dispatch is order-independent.
368
+ function matchesV1(root) {
369
+ var acinfo = pkix.signedEnvelopeTbs(root);
370
+ if (!acinfo) return false;
371
+ var k = acinfo.children;
372
+ if (!k || k.length < 6) return false;
373
+ if (!(k[0].tagClass === "context" && (k[0].tagNumber === 0 || k[0].tagNumber === 1) && k[0].children)) return false;
374
+ return !!k[1] && k[1].tagClass === "universal" && k[1].tagNumber === TAGS.SEQUENCE;
375
+ }
376
+
377
+ module.exports = {
378
+ parse: parse,
379
+ parseV1: parseV1,
380
+ pemDecode: pemDecode,
381
+ matches: matches,
382
+ matchesV1: matchesV1,
383
+ };
@@ -264,6 +264,26 @@ function generalName(ns, opts) {
264
264
  });
265
265
  }
266
266
 
267
+ // GeneralNames ::= SEQUENCE OF GeneralName (RFC 5280 §4.2.1.6), SIZE (1..MAX). Every
268
+ // element is validated by generalName (its CHOICE alternative's form + content) and
269
+ // surfaced raw, so a caller carrying a GeneralNames field cannot accept a malformed
270
+ // element by treating the whole sequence as opaque bytes. Returns { names: [{ bytes,
271
+ // tagClass, tagNumber }], bytes } (the decoded element list + the raw outer DER).
272
+ // `opts.implicitTag` handles a [tag] IMPLICIT GeneralNames — the context tag REPLACES
273
+ // the universal SEQUENCE tag (RFC 5755 Holder.entityName [1]); otherwise it is a bare
274
+ // universal SEQUENCE OF. `opts.code` is the caller's error code. Shared so the x509 /
275
+ // attribute-certificate / (future) CRMF parsers validate a GeneralNames identically.
276
+ function generalNames(ns, opts) {
277
+ opts = opts || {};
278
+ var code = opts.code || (ns.prefix + "/bad-general-names");
279
+ var gn = generalName(ns, { code: code });
280
+ function build(m) { return { names: m.items.map(function (it) { return it.value; }), bytes: m.node.bytes }; }
281
+ if (opts.implicitTag != null) {
282
+ return schema.implicitSeqOf(opts.implicitTag, gn, { min: 1, code: code, what: opts.what || "GeneralNames", build: build });
283
+ }
284
+ return schema.seqOf(gn, { assert: "sequence", min: 1, code: code, what: opts.what || "GeneralNames", build: build });
285
+ }
286
+
267
287
  // Extension ::= SEQUENCE { extnID OID, critical BOOLEAN DEFAULT FALSE,
268
288
  // extnValue OCTET STRING }. `critical` is a universal BOOLEAN present-by-count
269
289
  // (not context-tagged), so the per-extension decode handles the 2-vs-3-child
@@ -419,6 +439,7 @@ module.exports = {
419
439
  relativeDistinguishedName: relativeDistinguishedName,
420
440
  name: name,
421
441
  generalName: generalName,
442
+ generalNames: generalNames,
422
443
  attribute: attribute,
423
444
  extension: extension,
424
445
  extensions: extensions,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/pki",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:f3d799b0-bde8-4f05-8577-e064f7a420f1",
5
+ "serialNumber": "urn:uuid:7865e3f3-f43e-4cdb-818f-8a8fe2cf2d7a",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-07-06T00:04:32.672Z",
8
+ "timestamp": "2026-07-06T02:35:05.577Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/pki@0.1.13",
22
+ "bom-ref": "@blamejs/pki@0.1.14",
23
23
  "type": "application",
24
24
  "name": "pki",
25
- "version": "0.1.13",
25
+ "version": "0.1.14",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "Pure-JavaScript PKI toolkit that owns its stack — X.509, ASN.1/DER, CMS, PQC-first.",
29
- "purl": "pkg:npm/%40blamejs/pki@0.1.13",
29
+ "purl": "pkg:npm/%40blamejs/pki@0.1.14",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/pki@0.1.13",
57
+ "ref": "@blamejs/pki@0.1.14",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]