Initial Commit
This commit is contained in:
13
packages/security-stack/security/x509/src/schemas/index.ts
Normal file
13
packages/security-stack/security/x509/src/schemas/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
AuthorityKeyIdentifierSchema,
|
||||
BasicConstraintsSchema,
|
||||
CrlDistributionPointSchema,
|
||||
ExtendedKeyUsageSchema,
|
||||
GeneralNameSchema,
|
||||
KeyUsageSchema,
|
||||
NameConstraintsSchema,
|
||||
ParseCertificateOptionsSchema,
|
||||
SubjectKeyIdentifierSchema,
|
||||
ValidateChainOptionsSchema,
|
||||
X509CertificateLikeSchema,
|
||||
} from "./schemas.js";
|
||||
56
packages/security-stack/security/x509/src/schemas/schemas.md
Normal file
56
packages/security-stack/security/x509/src/schemas/schemas.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# `schemas/schemas` — Zod boundary schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines Zod schemas for every public option-bundle and value-object type exposed by `@suaveplan/x509`. The most important boundary is `ValidateChainOptionsSchema`, which `validateChain` uses to refuse malformed caller input — passing `null` for `leaf`, an array of non-certificate values for `intermediates`, or a non-`AbortSignal` for `signal` is rejected before any work happens, with the original Zod issue list preserved as `cause` on the `X509Error(CERTIFICATE_MALFORMED)` it wraps with.
|
||||
|
||||
Schemas for the extension result types (`BasicConstraintsSchema`, `KeyUsageSchema`, `ExtendedKeyUsageSchema`, `GeneralNameSchema`, `NameConstraintsSchema`, `AuthorityKeyIdentifierSchema`, `SubjectKeyIdentifierSchema`, `CrlDistributionPointSchema`) pin the structural shape so tests can assert via `safeParse` on synthetic inputs and so callers serialising through JSON can validate before consuming.
|
||||
|
||||
## Features
|
||||
|
||||
- Strict objects (`.strict()`) on option types so unknown keys are rejected.
|
||||
- Pass-through objects on value-object types where the runtime carries extra raw-bytes fields the schema doesn't enumerate.
|
||||
- `instanceof Uint8Array` refinements on byte-typed fields.
|
||||
- `instanceof AbortSignal` refinement on the optional cancellation signal.
|
||||
- Reuse of the same `GeneralNameSchema` across every extension that references one.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { ValidateChainOptionsSchema } from "@suaveplan/x509";
|
||||
|
||||
const parsed = ValidateChainOptionsSchema.parse({
|
||||
leaf: someCert,
|
||||
intermediates: [],
|
||||
trustAnchors: [root],
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { BasicConstraintsSchema } from "@suaveplan/x509";
|
||||
|
||||
const result = BasicConstraintsSchema.safeParse({
|
||||
cA: true,
|
||||
pathLenConstraint: 3,
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error("invariant violated");
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `ValidateChainOptionsSchema` — pins the `validateChain` boundary shape.
|
||||
- `X509CertificateLikeSchema` — pass-through certificate shape carrying `tbs`, `signatureAlgorithm`, `signatureValue`, `tbsCertificateBytes`, `raw`.
|
||||
- `BasicConstraintsSchema`, `KeyUsageSchema`, `ExtendedKeyUsageSchema`, `GeneralNameSchema`, `NameConstraintsSchema`, `AuthorityKeyIdentifierSchema`, `SubjectKeyIdentifierSchema`, `CrlDistributionPointSchema` — extension value shapes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- `validateChain` runs the options through Zod first; this is what produces the typed refusal of `{ leaf: null }` documented in the spec. The Zod issue is preserved as `cause` so consumers can inspect it programmatically.
|
||||
- `X509CertificateLikeSchema` is intentionally loose — the schema doesn't reproduce the full TBSCertificate Zod hierarchy. The runtime validator inside `validateChain` does the real structural work; the schema is a guard against gross caller bugs.
|
||||
- Schemas use `instanceof` refinements (`Uint8Array`, `AbortSignal`) so the runtime check matches the static type. A plain `{ buffer: ArrayBuffer }` would type-check but fail the `instanceof` predicate at runtime — exactly what the boundary is there to catch.
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildEd25519Chain } from "../__fixtures__/build-certs.js";
|
||||
import { X509Error } from "../errors/error-codes.js";
|
||||
import { decodeBasicConstraints } from "../extensions/basic-constraints.js";
|
||||
import { validateChain } from "../validate/validate-chain.js";
|
||||
import {
|
||||
AuthorityKeyIdentifierSchema,
|
||||
BasicConstraintsSchema,
|
||||
CrlDistributionPointSchema,
|
||||
ExtendedKeyUsageSchema,
|
||||
GeneralNameSchema,
|
||||
KeyUsageSchema,
|
||||
NameConstraintsSchema,
|
||||
ParseCertificateOptionsSchema,
|
||||
SubjectKeyIdentifierSchema,
|
||||
ValidateChainOptionsSchema,
|
||||
X509CertificateLikeSchema,
|
||||
} from "./schemas.js";
|
||||
|
||||
describe("Zod boundary schemas", () => {
|
||||
it("malformed ValidateChainOptions rejected at boundary with EXTENSION_MALFORMED", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
await expect(
|
||||
validateChain({
|
||||
leaf: null,
|
||||
intermediates: [],
|
||||
trustAnchors: [],
|
||||
} as unknown as Parameters<typeof validateChain>[0])
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("malformed BasicConstraints bytes rejected at boundary with EXTENSION_MALFORMED", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
try {
|
||||
decodeBasicConstraints(new Uint8Array([0x02, 0x01, 0x00]));
|
||||
throw new Error("expected throw");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(X509Error);
|
||||
expect((e as X509Error).code).toBe("X509.EXTENSION_MALFORMED");
|
||||
}
|
||||
});
|
||||
|
||||
it("X509CertificateLikeSchema validates a parsed cert", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { leafCert } = buildEd25519Chain();
|
||||
const ok = X509CertificateLikeSchema.safeParse(leafCert);
|
||||
expect(ok.success).toBe(true);
|
||||
});
|
||||
|
||||
it("X509CertificateLikeSchema rejects a non-object", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(X509CertificateLikeSchema.safeParse(null).success).toBe(false);
|
||||
});
|
||||
|
||||
it("BasicConstraintsSchema validates a struct", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
BasicConstraintsSchema.safeParse({ cA: true, pathLenConstraint: 0 })
|
||||
.success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("KeyUsageSchema validates the 9-flag shape", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
KeyUsageSchema.safeParse({
|
||||
digitalSignature: true,
|
||||
nonRepudiation: false,
|
||||
keyEncipherment: false,
|
||||
dataEncipherment: false,
|
||||
keyAgreement: false,
|
||||
keyCertSign: false,
|
||||
cRLSign: false,
|
||||
encipherOnly: false,
|
||||
decipherOnly: false,
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("ExtendedKeyUsageSchema rejects bad purposes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
ExtendedKeyUsageSchema.safeParse({ purposes: ["serverAuth"] })
|
||||
.success
|
||||
).toBe(true);
|
||||
expect(ExtendedKeyUsageSchema.safeParse({ purposes: 5 }).success).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it("GeneralNameSchema validates kind/value/raw shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(
|
||||
GeneralNameSchema.safeParse({
|
||||
kind: "dNSName",
|
||||
value: "ex.com",
|
||||
raw: new Uint8Array(),
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("NameConstraintsSchema accepts undefined fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(NameConstraintsSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("AuthorityKeyIdentifierSchema accepts empty AKI", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(AuthorityKeyIdentifierSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("SubjectKeyIdentifierSchema requires keyIdentifier", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(SubjectKeyIdentifierSchema.safeParse({}).success).toBe(false);
|
||||
expect(
|
||||
SubjectKeyIdentifierSchema.safeParse({
|
||||
keyIdentifier: new Uint8Array([1, 2, 3]),
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("CrlDistributionPointSchema accepts empty shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(CrlDistributionPointSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("ParseCertificateOptionsSchema accepts empty shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(ParseCertificateOptionsSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("ValidateChainOptionsSchema accepts a real shape", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const { leafCert, rootCert } = buildEd25519Chain();
|
||||
expect(
|
||||
ValidateChainOptionsSchema.safeParse({
|
||||
leaf: leafCert,
|
||||
intermediates: [],
|
||||
trustAnchors: [rootCert],
|
||||
}).success
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
130
packages/security-stack/security/x509/src/schemas/schemas.ts
Normal file
130
packages/security-stack/security/x509/src/schemas/schemas.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Schema for an `X509Certificate`-shaped value object. Used at the boundary
|
||||
* of `validateChain` so callers that pass through `JSON.parse` get a typed
|
||||
* refusal rather than a confusing crash deep inside the validator.
|
||||
*/
|
||||
export const X509CertificateLikeSchema = z
|
||||
.object({
|
||||
tbs: z.object({}).passthrough(),
|
||||
signatureAlgorithm: z.object({}).passthrough(),
|
||||
signatureValue: z.instanceof(Uint8Array),
|
||||
tbsCertificateBytes: z.instanceof(Uint8Array),
|
||||
raw: z.instanceof(Uint8Array),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const ValidateChainOptionsSchema = z
|
||||
.object({
|
||||
leaf: X509CertificateLikeSchema,
|
||||
intermediates: z.array(X509CertificateLikeSchema),
|
||||
trustAnchors: z.array(X509CertificateLikeSchema),
|
||||
clock: z.function().optional(),
|
||||
signal: z.instanceof(AbortSignal).optional(),
|
||||
maxChainDepth: z.number().int().positive().optional(),
|
||||
ocspResponder: z.object({ check: z.function() }).optional(),
|
||||
hostName: z.string().optional(),
|
||||
ekuRequired: z.string().optional(),
|
||||
initialExplicitPolicy: z.boolean().optional(),
|
||||
initialPolicySet: z.array(z.string()).optional(),
|
||||
})
|
||||
// The Zod schema is intentionally loose — the validator does the real
|
||||
// structural validation. The schema's job is to catch obvious caller
|
||||
// bugs (null leaf, wrong array element types).
|
||||
.strict() as unknown as z.ZodType<
|
||||
// The runtime cast keeps the callsite happy; ValidateChainOptions has
|
||||
// more precise generic information than Zod can express.
|
||||
import("../validate/validate-chain.types.js").ValidateChainOptions
|
||||
>;
|
||||
|
||||
export const ParseCertificateOptionsSchema = z
|
||||
.object({
|
||||
// No options at this time. Reserved for future extension knobs.
|
||||
})
|
||||
.strict();
|
||||
|
||||
/**
|
||||
* Lightweight schemas for the extension result types. These are not used at
|
||||
* a runtime boundary today (extension decoders construct values directly),
|
||||
* but they pin the shape so tests can assert via `safeParse` on synthetic
|
||||
* inputs and so callers passing through serialisation can validate.
|
||||
*/
|
||||
export const BasicConstraintsSchema = z.object({
|
||||
cA: z.boolean(),
|
||||
pathLenConstraint: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const KeyUsageSchema = z.object({
|
||||
digitalSignature: z.boolean(),
|
||||
nonRepudiation: z.boolean(),
|
||||
keyEncipherment: z.boolean(),
|
||||
dataEncipherment: z.boolean(),
|
||||
keyAgreement: z.boolean(),
|
||||
keyCertSign: z.boolean(),
|
||||
cRLSign: z.boolean(),
|
||||
encipherOnly: z.boolean(),
|
||||
decipherOnly: z.boolean(),
|
||||
});
|
||||
|
||||
export const ExtendedKeyUsageSchema = z.object({
|
||||
purposes: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const GeneralNameSchema = z.object({
|
||||
kind: z.enum([
|
||||
"rfc822Name",
|
||||
"dNSName",
|
||||
"uniformResourceIdentifier",
|
||||
"iPAddress",
|
||||
"directoryName",
|
||||
"otherName",
|
||||
"x400Address",
|
||||
"ediPartyName",
|
||||
"registeredID",
|
||||
]),
|
||||
value: z.string(),
|
||||
raw: z.instanceof(Uint8Array),
|
||||
});
|
||||
|
||||
export const NameConstraintsSchema = z.object({
|
||||
permittedSubtrees: z
|
||||
.array(
|
||||
z.object({
|
||||
base: GeneralNameSchema,
|
||||
minimum: z.number().int().optional(),
|
||||
maximum: z.number().int().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
excludedSubtrees: z
|
||||
.array(
|
||||
z.object({
|
||||
base: GeneralNameSchema,
|
||||
minimum: z.number().int().optional(),
|
||||
maximum: z.number().int().optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const AuthorityKeyIdentifierSchema = z.object({
|
||||
keyIdentifier: z.instanceof(Uint8Array).optional(),
|
||||
authorityCertIssuer: z.array(GeneralNameSchema).optional(),
|
||||
authorityCertSerialNumber: z.instanceof(Uint8Array).optional(),
|
||||
});
|
||||
|
||||
export const SubjectKeyIdentifierSchema = z.object({
|
||||
keyIdentifier: z.instanceof(Uint8Array),
|
||||
});
|
||||
|
||||
export const CrlDistributionPointSchema = z.object({
|
||||
distributionPoint: z
|
||||
.object({
|
||||
fullName: z.array(GeneralNameSchema).optional(),
|
||||
nameRelativeToCRLIssuer: z.instanceof(Uint8Array).optional(),
|
||||
})
|
||||
.optional(),
|
||||
reasons: z.instanceof(Uint8Array).optional(),
|
||||
cRLIssuer: z.array(GeneralNameSchema).optional(),
|
||||
});
|
||||
Reference in New Issue
Block a user