@uwmd/signing 0.1.0

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/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # @uwmd/signing
2
+
3
+ Block, receipt, and module-manifest signatures for
4
+ [UW Markdown](https://uwmd.org) — the implementation of **protocol §V.11**
5
+ ([RFC 0010](../../docs/rfcs/0010-signed-blocks.md)) and **§X.1**
6
+ ([RFC 0002](../../docs/rfcs/0002-module-signing.md)).
7
+
8
+ ## Why this is a separate package
9
+
10
+ `@uwmd/core` is deliberately zero-cryptography. Reading, validating, editing,
11
+ and computing over a `.uw.md` file requires no crypto, and the overwhelming
12
+ majority of adopters should never take a crypto dependency to do any of it.
13
+
14
+ Signed blocks are for the deployments that genuinely need chain of custody:
15
+ regulated lender data rooms, multi-party deal flow where sponsor, lender, and
16
+ appraiser each sign their own sections, and agent-host accountability where a
17
+ signature proves *which* agent instance wrote a block rather than merely what
18
+ the `actor` field claims.
19
+
20
+ Core owns the normative, crypto-free half — the wire shape (`_meta.signature`)
21
+ and the canonical signing input. This package owns the algorithms. Two seams
22
+ connect them:
23
+
24
+ ```ts
25
+ verifyChain(parsed, { signatureVerifier }) // blocks
26
+ verifyReceipt(receipt, source, { signatureVerifier }) // receipts (RFC 0016)
27
+ ```
28
+
29
+ Without a verifier, core reports signatures as **present and unchecked** —
30
+ never as valid.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ npm install @uwmd/signing
36
+ ```
37
+
38
+ Needs Node ≥ 18.4 (Ed25519 in Web Crypto) or a modern browser. `ed25519`,
39
+ `es256`, and `es384` are the admitted algorithms.
40
+
41
+ ## Signing a block
42
+
43
+ ```ts
44
+ import { computeBlockHash, parseUWFile } from '@uwmd/core';
45
+ import { generateSigningKeyPair, signBlock, stampBlockSignature } from '@uwmd/signing';
46
+
47
+ const { signing, verifying } = await generateSigningKeyPair('ed25519', 'sponsor-2026');
48
+
49
+ const parsed = parseUWFile(source);
50
+ const block = parsed.sections.rent_roll;
51
+
52
+ // A block must already carry `content_hash`: a signature over an absent hash
53
+ // commits to nothing, and validates as INT-05.
54
+ const signature = await signBlock(block, signing);
55
+ const signed = stampBlockSignature(block, signature);
56
+ ```
57
+
58
+ `signBlock` does not mutate the block, and `generateSigningKeyPair` is for
59
+ tests and local development — production keys belong in an HSM or cloud KMS
60
+ behind a custom `KeyStore`.
61
+
62
+ ## Verifying
63
+
64
+ ```ts
65
+ import { verifyChain } from '@uwmd/core';
66
+ import { createBlockSignatureVerifier, loadKeyStoreFile } from '@uwmd/signing';
67
+
68
+ const store = await loadKeyStoreFile('./keystore.json');
69
+ const result = await verifyChain(parsed, {
70
+ signatureVerifier: createBlockSignatureVerifier(store),
71
+ });
72
+
73
+ result.signatures_present; // how many blocks carry a signature
74
+ result.signatures_verified; // how many actually verified
75
+ ```
76
+
77
+ Or from the CLI:
78
+
79
+ ```bash
80
+ uwmd verify deal.uwx.md --signing --keystore=./keystore.json
81
+ ```
82
+
83
+ ### What each code means
84
+
85
+ | Code | Severity | Trigger |
86
+ |---|---|---|
87
+ | `INT-05` | error | `signature` present with no `content_hash`. |
88
+ | `INT-06` | error | `kid` names a key the store does not hold. |
89
+ | `INT-07` | error | The signature does not verify, the algorithm is unadmitted, or the stamped `content_hash` no longer recomputes. |
90
+ | `INT-08` | warning | The algorithm is in the deployment's deprecation list. |
91
+
92
+ `INT-06` and `INT-07` are deliberately distinct. "I cannot check this" and
93
+ "this is forged" call for opposite responses — load a key versus reject the
94
+ document — and a verifier that merges them tells an operator to re-sign when
95
+ the real fix is to configure their key store.
96
+
97
+ ## Key store format
98
+
99
+ Key *distribution* is out of scope: a public key that travels inside the
100
+ document it authenticates proves nothing. The file format below is a
101
+ **reference**, not a normative one — back a `KeyStore` with an HSM and never
102
+ touch it.
103
+
104
+ ```json
105
+ {
106
+ "keystore_version": "1",
107
+ "keys": [
108
+ {
109
+ "kid": "sponsor-2026",
110
+ "alg": "ed25519",
111
+ "public_key_jwk": { "kty": "OKP", "crv": "Ed25519", "x": "..." }
112
+ },
113
+ {
114
+ "kid": "lender-2026",
115
+ "alg": "es256",
116
+ "public_key_spki": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."
117
+ }
118
+ ]
119
+ }
120
+ ```
121
+
122
+ Exactly one of `public_key_jwk` / `public_key_spki` per entry. Every key is
123
+ imported eagerly at load time, so a typo fails once, loudly, instead of
124
+ appearing later as a per-block `INT-07` that reads like tampering.
125
+
126
+ **Rotation** is "issue new blocks under a new `kid` and keep the old key
127
+ loaded". A `kid` names one key and MUST NOT be reused; blocks signed under a
128
+ retired `kid` stay verifiable for as long as the store retains it.
129
+
130
+ ## Module manifests
131
+
132
+ Module signing (RFC 0002, protocol §X.1) uses the same keys and the same
133
+ verifier. The signed bytes are the RFC 8785 canonical manifest with `signature`
134
+ removed.
135
+
136
+ ```ts
137
+ import { loadModuleManifestAsync } from '@uwmd/core';
138
+ import { createModuleSignatureVerifier, signModule, stampModuleSignature } from '@uwmd/signing';
139
+
140
+ const signed = stampModuleSignature(
141
+ manifest,
142
+ await signModule(manifest, signing, { identity: 'modules@example.org' }),
143
+ );
144
+
145
+ const result = await loadModuleManifestAsync(signed, {
146
+ signaturePolicy: 'require', // or 'verify-if-present' / 'ignore'
147
+ signatureVerifier: createModuleSignatureVerifier(store),
148
+ });
149
+ ```
150
+
151
+ The three policies are `ignore` (default), `verify-if-present` (an unsigned
152
+ module loads, a broken signature refuses), and `require`. Both checking
153
+ policies refuse when the key store lacks the `kid` — a host that cannot check a
154
+ signature has established nothing about the module.
155
+
156
+ `identity` is **advisory**. A signature proves the key holder asserted it,
157
+ never that the assertion is true; an `allowedIdentities` allow-list is worth
158
+ only as much as your decision to bind that `kid` to that identity.
159
+
160
+ ## Receipts
161
+
162
+ Receipt signing (RFC 0016) uses the same keys:
163
+
164
+ ```ts
165
+ import { verifyReceipt } from '@uwmd/core';
166
+ import { createReceiptSignatureVerifier, signReceipt, stampReceiptSignature } from '@uwmd/signing';
167
+
168
+ const signed = stampReceiptSignature(receipt, await signReceipt(receipt, signing));
169
+
170
+ const verdict = await verifyReceipt(signed, source, {
171
+ signatureVerifier: createReceiptSignatureVerifier(store),
172
+ });
173
+ ```
174
+
175
+ Without a verifier, a signed receipt verifies as `unverifiable` with `RCP-08`.
176
+
177
+ ## Limits
178
+
179
+ - **`signed_at` is self-asserted.** Audit-grade non-repudiation needs a
180
+ timestamping authority countersignature, which protocol 1.x does not define.
181
+ - **No selective disclosure.** Signing a redacted block would need Merkle
182
+ commitments over fields; the current design has none.
183
+ - **Re-rooting a chain requires re-signing.** `content_hash` covers `_meta`,
184
+ and `parent_hash` lives there — see the erratum in RFC 0010.
185
+ - **No Sigstore.** Keyless signing needs a Fulcio trust root and a Rekor
186
+ inclusion proof — a vendored snapshot that fails closed when stale, or
187
+ network access at verify time. `scheme: "sigstore"` is reserved in §X.1.2 so
188
+ adding it later stays additive.
189
+
190
+ MIT. Part of the [UW Markdown](https://github.com/UWMD-OSP/UW-Markdown) monorepo.
@@ -0,0 +1,21 @@
1
+ import type { UWSignatureAlgorithm } from '@uwmd/core';
2
+ export interface AlgorithmParams {
3
+ /** Passed to `importKey` / `generateKey`. */
4
+ readonly keyParams: EcKeyImportParams | Algorithm;
5
+ /** Passed to `sign` / `verify`. */
6
+ readonly signParams: EcdsaParams | Algorithm;
7
+ /** JWK `kty` the key material must declare. */
8
+ readonly kty: 'OKP' | 'EC';
9
+ /** JWK `crv` the key material must declare. */
10
+ readonly crv: string;
11
+ }
12
+ export declare function algorithmParams(alg: string): AlgorithmParams;
13
+ export declare function isKnownAlgorithm(alg: string): alg is UWSignatureAlgorithm;
14
+ /**
15
+ * The runtime's Web Crypto implementation.
16
+ *
17
+ * Node exposes it as a global from 18.0; Ed25519 specifically landed in 18.4.
18
+ * An older runtime fails here with a code rather than a `TypeError` on
19
+ * `undefined.subtle`.
20
+ */
21
+ export declare function subtle(): SubtleCrypto;
@@ -0,0 +1,55 @@
1
+ // Algorithm parameters for the three algorithms protocol §V.11 admits.
2
+ //
3
+ // The shortlist is borrowed from PASETO v4 and JOSE: one EdDSA curve and two
4
+ // NIST P-curves, all three present in stable Web Crypto. Deliberately no RSA —
5
+ // a 256-byte signature inline in a `_meta` block is a size the format should
6
+ // not have to carry, and nothing in CRE needs it.
7
+ //
8
+ // ECDSA here is the JOSE convention: raw `r || s` (IEEE P1363), which is what
9
+ // Web Crypto produces and consumes. DER-wrapped ECDSA signatures are NOT
10
+ // interchangeable and will fail verification rather than silently pass.
11
+ import { SigningError } from './errors.js';
12
+ const PARAMS = {
13
+ ed25519: {
14
+ keyParams: { name: 'Ed25519' },
15
+ signParams: { name: 'Ed25519' },
16
+ kty: 'OKP',
17
+ crv: 'Ed25519',
18
+ },
19
+ es256: {
20
+ keyParams: { name: 'ECDSA', namedCurve: 'P-256' },
21
+ signParams: { name: 'ECDSA', hash: 'SHA-256' },
22
+ kty: 'EC',
23
+ crv: 'P-256',
24
+ },
25
+ es384: {
26
+ keyParams: { name: 'ECDSA', namedCurve: 'P-384' },
27
+ signParams: { name: 'ECDSA', hash: 'SHA-384' },
28
+ kty: 'EC',
29
+ crv: 'P-384',
30
+ },
31
+ };
32
+ export function algorithmParams(alg) {
33
+ const params = PARAMS[alg];
34
+ if (!params) {
35
+ throw new SigningError('SIG_ALGORITHM_UNSUPPORTED', `Unknown signature algorithm '${alg}'. Protocol 1.x admits ed25519, es256, es384.`);
36
+ }
37
+ return params;
38
+ }
39
+ export function isKnownAlgorithm(alg) {
40
+ return alg in PARAMS;
41
+ }
42
+ /**
43
+ * The runtime's Web Crypto implementation.
44
+ *
45
+ * Node exposes it as a global from 18.0; Ed25519 specifically landed in 18.4.
46
+ * An older runtime fails here with a code rather than a `TypeError` on
47
+ * `undefined.subtle`.
48
+ */
49
+ export function subtle() {
50
+ const provider = globalThis.crypto?.subtle;
51
+ if (!provider) {
52
+ throw new SigningError('SIG_NO_CRYPTO', 'No Web Crypto provider (globalThis.crypto.subtle) in this runtime; @uwmd/signing needs Node >= 18.4 or a browser.');
53
+ }
54
+ return provider;
55
+ }
@@ -0,0 +1,9 @@
1
+ export declare function toBase64Url(bytes: Uint8Array): string;
2
+ export declare function fromBase64Url(text: string): Uint8Array;
3
+ /**
4
+ * Decode either base64 or base64url. Key material arrives from openssl (base64,
5
+ * padded) far more often than from a JS encoder, and refusing the common form
6
+ * on a purely cosmetic difference would be a bad trade for zero safety.
7
+ */
8
+ export declare function fromBase64Any(text: string): Uint8Array;
9
+ export declare function utf8(text: string): Uint8Array;
package/dist/base64.js ADDED
@@ -0,0 +1,60 @@
1
+ // Base64url (RFC 4648 §5, unpadded) and base64 helpers.
2
+ //
3
+ // Hand-rolled rather than reaching for Node's Buffer: this package is
4
+ // browser-usable, and `_meta.signature.sig` must round-trip identically on both
5
+ // sides or every signature written by a browser fails in Node.
6
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
7
+ const B64 = `${ALPHABET}+/`;
8
+ const B64URL = `${ALPHABET}-_`;
9
+ function encodeWith(bytes, alphabet, pad) {
10
+ let out = '';
11
+ for (let i = 0; i < bytes.length; i += 3) {
12
+ const b0 = bytes[i];
13
+ const b1 = bytes[i + 1];
14
+ const b2 = bytes[i + 2];
15
+ const triple = (b0 << 16) | ((b1 ?? 0) << 8) | (b2 ?? 0);
16
+ out += alphabet[(triple >> 18) & 63];
17
+ out += alphabet[(triple >> 12) & 63];
18
+ out += b1 === undefined ? (pad ? '=' : '') : alphabet[(triple >> 6) & 63];
19
+ out += b2 === undefined ? (pad ? '=' : '') : alphabet[triple & 63];
20
+ }
21
+ return out;
22
+ }
23
+ function decodeWith(text, alphabet) {
24
+ const clean = text.replace(/=+$/, '');
25
+ const bytes = [];
26
+ let acc = 0;
27
+ let bits = 0;
28
+ for (const ch of clean) {
29
+ const value = alphabet.indexOf(ch);
30
+ if (value < 0)
31
+ throw new Error(`invalid base64 character '${ch}'`);
32
+ acc = (acc << 6) | value;
33
+ bits += 6;
34
+ if (bits >= 8) {
35
+ bits -= 8;
36
+ bytes.push((acc >> bits) & 0xff);
37
+ }
38
+ }
39
+ return Uint8Array.from(bytes);
40
+ }
41
+ export function toBase64Url(bytes) {
42
+ return encodeWith(bytes, B64URL, false);
43
+ }
44
+ export function fromBase64Url(text) {
45
+ return decodeWith(text, B64URL);
46
+ }
47
+ /**
48
+ * Decode either base64 or base64url. Key material arrives from openssl (base64,
49
+ * padded) far more often than from a JS encoder, and refusing the common form
50
+ * on a purely cosmetic difference would be a bad trade for zero safety.
51
+ */
52
+ export function fromBase64Any(text) {
53
+ const normalized = text.trim().replace(/\s+/g, '');
54
+ return /[-_]/.test(normalized)
55
+ ? decodeWith(normalized, B64URL)
56
+ : decodeWith(normalized, B64);
57
+ }
58
+ export function utf8(text) {
59
+ return new TextEncoder().encode(text);
60
+ }
@@ -0,0 +1,15 @@
1
+ export type SigningErrorCode =
2
+ /** No Web Crypto provider (`crypto.subtle`) in this runtime. */
3
+ 'SIG_NO_CRYPTO'
4
+ /** The runtime rejected the algorithm — e.g. Ed25519 on a pre-18.4 Node. */
5
+ | 'SIG_ALGORITHM_UNSUPPORTED'
6
+ /** Key material could not be imported (wrong format, wrong curve, corrupt). */
7
+ | 'SIG_BAD_KEY'
8
+ /** The block cannot produce a signing input — almost always a missing hash. */
9
+ | 'SIG_UNSIGNABLE'
10
+ /** A key-store document is malformed. */
11
+ | 'SIG_BAD_KEYSTORE';
12
+ export declare class SigningError extends Error {
13
+ readonly code: SigningErrorCode;
14
+ constructor(code: SigningErrorCode, message: string);
15
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,14 @@
1
+ // Typed errors for the signing package.
2
+ //
3
+ // Mirrors the repo convention (`ProtocolError`, `CalcError`, `ExcelEmitError`):
4
+ // a bare `Error` from a crypto path is unactionable, because the four things
5
+ // that go wrong here — no provider, unusable key material, an unsignable block,
6
+ // an unreadable key store — call for four different fixes.
7
+ export class SigningError extends Error {
8
+ code;
9
+ constructor(code, message) {
10
+ super(`[${code}] ${message}`);
11
+ this.name = 'SigningError';
12
+ this.code = code;
13
+ }
14
+ }
@@ -0,0 +1,16 @@
1
+ export { SigningError } from './errors.js';
2
+ export type { SigningErrorCode } from './errors.js';
3
+ export { toBase64Url, fromBase64Url, fromBase64Any } from './base64.js';
4
+ export { isKnownAlgorithm } from './algorithms.js';
5
+ export { InMemoryKeyStore, importPublicKey, importPrivateKey, generateSigningKeyPair, exportPublicKeyJwk, } from './keys.js';
6
+ export type { KeyStore, SignerKey, SigningKey, PublicKeyMaterial } from './keys.js';
7
+ export { loadKeyStoreDocument, parseKeyStore, loadKeyStoreFile, } from './keystore-file.js';
8
+ export type { KeyStoreDocument, KeyStoreEntry } from './keystore-file.js';
9
+ export { signBlock, stampBlockSignature, signReceipt, stampReceiptSignature } from './sign.js';
10
+ export type { SignBlockOptions } from './sign.js';
11
+ export { verifyBlockSignature, createBlockSignatureVerifier, createReceiptSignatureVerifier, } from './verify.js';
12
+ export type { BlockVerification, SigVerifyError } from './verify.js';
13
+ export { signModule, stampModuleSignature, createModuleSignatureVerifier } from './modules.js';
14
+ export type { SignModuleOptions } from './modules.js';
15
+ export declare const SIGNING_PACKAGE_NAME: "@uwmd/signing";
16
+ export declare const SIGNING_VERSION = "0.1.0";
package/dist/index.js ADDED
@@ -0,0 +1,23 @@
1
+ // @uwmd/signing — block and receipt signatures for UW Markdown (RFC 0010).
2
+ //
3
+ // Why this is a separate package: `@uwmd/core` is deliberately
4
+ // zero-cryptography. Signed blocks are for regulated lender data rooms and
5
+ // multi-party deal flow, not the everyday case, and the overwhelming majority
6
+ // of adopters should never take a crypto dependency to read an underwriting
7
+ // file. Core defines the wire shape and the canonical signing input (both
8
+ // normative, both crypto-free); the algorithms live here.
9
+ //
10
+ // Two seams connect them, and neither is a back door: `verifyChain(parsed,
11
+ // { signatureVerifier })` for blocks, and `verifyReceipt(..., {
12
+ // signatureVerifier })` for receipts. Without a verifier, core reports that
13
+ // signatures were *present and unchecked* rather than treating them as valid.
14
+ export { SigningError } from './errors.js';
15
+ export { toBase64Url, fromBase64Url, fromBase64Any } from './base64.js';
16
+ export { isKnownAlgorithm } from './algorithms.js';
17
+ export { InMemoryKeyStore, importPublicKey, importPrivateKey, generateSigningKeyPair, exportPublicKeyJwk, } from './keys.js';
18
+ export { loadKeyStoreDocument, parseKeyStore, loadKeyStoreFile, } from './keystore-file.js';
19
+ export { signBlock, stampBlockSignature, signReceipt, stampReceiptSignature } from './sign.js';
20
+ export { verifyBlockSignature, createBlockSignatureVerifier, createReceiptSignatureVerifier, } from './verify.js';
21
+ export { signModule, stampModuleSignature, createModuleSignatureVerifier } from './modules.js';
22
+ export const SIGNING_PACKAGE_NAME = '@uwmd/signing';
23
+ export const SIGNING_VERSION = '0.1.0';
package/dist/keys.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type { UWSignatureAlgorithm } from '@uwmd/core';
2
+ /** A public key a verifier trusts, resolved from a `kid`. */
3
+ export interface SignerKey {
4
+ kid: string;
5
+ alg: UWSignatureAlgorithm;
6
+ publicKey: CryptoKey;
7
+ }
8
+ /** A private key a signer holds. Never serialized by this package. */
9
+ export interface SigningKey {
10
+ kid: string;
11
+ alg: UWSignatureAlgorithm;
12
+ privateKey: CryptoKey;
13
+ }
14
+ /**
15
+ * Resolves a `kid` to the public key a verifier should check against.
16
+ *
17
+ * `null` means "I do not hold this key" and MUST NOT be conflated with "the
18
+ * signature is bad" — they are INT-06 and INT-07 respectively, and a verifier
19
+ * that merges them tells an operator to re-sign when the real fix is to load a
20
+ * key.
21
+ */
22
+ export interface KeyStore {
23
+ resolve(kid: string): Promise<SignerKey | null>;
24
+ }
25
+ /** Key material as it appears in a key-store document. */
26
+ export type PublicKeyMaterial =
27
+ /** JWK — the portable form; `kty`/`crv` are checked against `alg`. */
28
+ {
29
+ jwk: JsonWebKey;
30
+ }
31
+ /** PKIX SubjectPublicKeyInfo, base64 or base64url, with or without padding. */
32
+ | {
33
+ spki: string;
34
+ };
35
+ export declare function importPublicKey(alg: string, material: PublicKeyMaterial): Promise<CryptoKey>;
36
+ export declare function importPrivateKey(alg: string, material: {
37
+ jwk: JsonWebKey;
38
+ } | {
39
+ pkcs8: string;
40
+ }): Promise<CryptoKey>;
41
+ /** An in-memory {@link KeyStore}. The reference verifier for tests and hosts. */
42
+ export declare class InMemoryKeyStore implements KeyStore {
43
+ private readonly keys;
44
+ constructor(keys?: Iterable<SignerKey>);
45
+ add(key: SignerKey): this;
46
+ resolve(kid: string): Promise<SignerKey | null>;
47
+ /**
48
+ * Key ids currently held. Key *rotation* is "issue under a new kid and keep
49
+ * the old one loaded" (RFC 0010 §Unresolved questions), so a store routinely
50
+ * holds several keys for one logical signer and this is how an operator sees
51
+ * that it does.
52
+ */
53
+ get kids(): string[];
54
+ }
55
+ /**
56
+ * Generate a key pair for one of the admitted algorithms.
57
+ *
58
+ * Present for tests, local development, and `uwmd keygen`-style tooling — not a
59
+ * key-management system. Production keys belong in an HSM or cloud KMS behind a
60
+ * custom {@link KeyStore}.
61
+ */
62
+ export declare function generateSigningKeyPair(alg: UWSignatureAlgorithm, kid: string): Promise<{
63
+ signing: SigningKey;
64
+ verifying: SignerKey;
65
+ }>;
66
+ /** Export a public key as a JWK, for writing into a key-store document. */
67
+ export declare function exportPublicKeyJwk(key: SignerKey): Promise<JsonWebKey>;
package/dist/keys.js ADDED
@@ -0,0 +1,102 @@
1
+ // Key import/export and the key-store contract.
2
+ //
3
+ // Key *distribution* is deliberately out of scope — RFC 0010 §Alternatives
4
+ // rejects inline public keys precisely because a key that travels with the
5
+ // document it authenticates proves nothing. What this file defines is the
6
+ // narrow thing the spec does need: how a `kid` resolves to usable key material.
7
+ import { algorithmParams, isKnownAlgorithm, subtle } from './algorithms.js';
8
+ import { fromBase64Any } from './base64.js';
9
+ import { SigningError } from './errors.js';
10
+ export async function importPublicKey(alg, material) {
11
+ return importKey(alg, material, 'public');
12
+ }
13
+ export async function importPrivateKey(alg, material) {
14
+ const normalized = 'jwk' in material ? { jwk: material.jwk } : { spki: material.pkcs8 };
15
+ return importKey(alg, normalized, 'private');
16
+ }
17
+ async function importKey(alg, material, kind) {
18
+ if (!isKnownAlgorithm(alg)) {
19
+ throw new SigningError('SIG_ALGORITHM_UNSUPPORTED', `Unknown signature algorithm '${alg}'.`);
20
+ }
21
+ const params = algorithmParams(alg);
22
+ const usages = kind === 'public' ? ['verify'] : ['sign'];
23
+ try {
24
+ if ('jwk' in material) {
25
+ assertJwkMatchesAlgorithm(alg, material.jwk, params.kty, params.crv);
26
+ return await subtle().importKey('jwk', material.jwk, params.keyParams, true, usages);
27
+ }
28
+ const bytes = fromBase64Any(material.spki);
29
+ const format = kind === 'public' ? 'spki' : 'pkcs8';
30
+ return await subtle().importKey(format, bytes, params.keyParams, true, usages);
31
+ }
32
+ catch (error) {
33
+ if (error instanceof SigningError)
34
+ throw error;
35
+ throw new SigningError('SIG_BAD_KEY', `Could not import the ${kind} key for '${alg}': ${error.message}`);
36
+ }
37
+ }
38
+ /**
39
+ * Reject a JWK whose curve disagrees with the declared `alg` *before* handing
40
+ * it to Web Crypto.
41
+ *
42
+ * Without this, a P-256 key labelled `es384` imports cleanly on some runtimes
43
+ * and then fails every verification, which reads as "the document was tampered
44
+ * with" when the truth is "the key store is mislabelled".
45
+ */
46
+ function assertJwkMatchesAlgorithm(alg, jwk, kty, crv) {
47
+ if (jwk.kty !== kty || jwk.crv !== crv) {
48
+ throw new SigningError('SIG_BAD_KEY', `Key declares alg '${alg}' (expects kty=${kty}, crv=${crv}) but the JWK is kty=${String(jwk.kty)}, crv=${String(jwk.crv)}.`);
49
+ }
50
+ }
51
+ /** An in-memory {@link KeyStore}. The reference verifier for tests and hosts. */
52
+ export class InMemoryKeyStore {
53
+ keys = new Map();
54
+ constructor(keys = []) {
55
+ for (const key of keys)
56
+ this.add(key);
57
+ }
58
+ add(key) {
59
+ this.keys.set(key.kid, key);
60
+ return this;
61
+ }
62
+ async resolve(kid) {
63
+ return this.keys.get(kid) ?? null;
64
+ }
65
+ /**
66
+ * Key ids currently held. Key *rotation* is "issue under a new kid and keep
67
+ * the old one loaded" (RFC 0010 §Unresolved questions), so a store routinely
68
+ * holds several keys for one logical signer and this is how an operator sees
69
+ * that it does.
70
+ */
71
+ get kids() {
72
+ return [...this.keys.keys()];
73
+ }
74
+ }
75
+ /**
76
+ * Generate a key pair for one of the admitted algorithms.
77
+ *
78
+ * Present for tests, local development, and `uwmd keygen`-style tooling — not a
79
+ * key-management system. Production keys belong in an HSM or cloud KMS behind a
80
+ * custom {@link KeyStore}.
81
+ */
82
+ export async function generateSigningKeyPair(alg, kid) {
83
+ const params = algorithmParams(alg);
84
+ let pair;
85
+ try {
86
+ pair = (await subtle().generateKey(params.keyParams, true, [
87
+ 'sign',
88
+ 'verify',
89
+ ]));
90
+ }
91
+ catch (error) {
92
+ throw new SigningError('SIG_ALGORITHM_UNSUPPORTED', `This runtime cannot generate '${alg}' keys: ${error.message}`);
93
+ }
94
+ return {
95
+ signing: { kid, alg, privateKey: pair.privateKey },
96
+ verifying: { kid, alg, publicKey: pair.publicKey },
97
+ };
98
+ }
99
+ /** Export a public key as a JWK, for writing into a key-store document. */
100
+ export async function exportPublicKeyJwk(key) {
101
+ return subtle().exportKey('jwk', key.publicKey);
102
+ }
@@ -0,0 +1,32 @@
1
+ import type { UWSignatureAlgorithm } from '@uwmd/core';
2
+ import { type KeyStore } from './keys.js';
3
+ /** One entry. Exactly one of `public_key_jwk` / `public_key_spki` is required. */
4
+ export interface KeyStoreEntry {
5
+ kid: string;
6
+ alg: UWSignatureAlgorithm;
7
+ public_key_jwk?: JsonWebKey;
8
+ /** PKIX SubjectPublicKeyInfo, base64 (what `openssl pkey -pubout` emits). */
9
+ public_key_spki?: string;
10
+ /** Free-text, for humans reading the file. Never consulted by verification. */
11
+ description?: string;
12
+ }
13
+ export interface KeyStoreDocument {
14
+ /** `"1"` today. A reader MUST refuse a version it does not know. */
15
+ keystore_version: string;
16
+ keys: KeyStoreEntry[];
17
+ }
18
+ /**
19
+ * Parse and import a key-store document into an {@link InMemoryKeyStore}.
20
+ *
21
+ * Every key is imported eagerly. Deferring import to first use would turn a
22
+ * typo in the store into a per-block INT-07, which reads as tampering; failing
23
+ * once, loudly, at load time says what is actually wrong.
24
+ */
25
+ export declare function loadKeyStoreDocument(document: unknown): Promise<KeyStore>;
26
+ /** Parse a key-store document from JSON text. */
27
+ export declare function parseKeyStore(json: string): Promise<KeyStore>;
28
+ /**
29
+ * Read a key-store document from disk. Node-only by construction — the import
30
+ * is dynamic so that bundling this module for a browser does not pull in `fs`.
31
+ */
32
+ export declare function loadKeyStoreFile(path: string): Promise<KeyStore>;
@@ -0,0 +1,120 @@
1
+ // The reference key-store document: a JSON file mapping `kid` to a public key.
2
+ //
3
+ // RFC 0010 takes no position on key distribution, so this is explicitly a
4
+ // *reference* format, not a normative one — an adopter backing their store with
5
+ // an HSM implements `KeyStore` directly and never touches this file. It exists
6
+ // so that `uwmd verify --signing --keystore=<path>` has something to read and
7
+ // so the conformance fixtures have a portable way to ship a public key.
8
+ import { isKnownAlgorithm } from './algorithms.js';
9
+ import { SigningError } from './errors.js';
10
+ import { InMemoryKeyStore, importPublicKey } from './keys.js';
11
+ const SUPPORTED_KEYSTORE_VERSIONS = ['1'];
12
+ /**
13
+ * Parse and import a key-store document into an {@link InMemoryKeyStore}.
14
+ *
15
+ * Every key is imported eagerly. Deferring import to first use would turn a
16
+ * typo in the store into a per-block INT-07, which reads as tampering; failing
17
+ * once, loudly, at load time says what is actually wrong.
18
+ */
19
+ export async function loadKeyStoreDocument(document) {
20
+ const parsed = assertKeyStoreDocument(document);
21
+ const keys = [];
22
+ for (const entry of parsed.keys) {
23
+ const material = entry.public_key_jwk
24
+ ? { jwk: entry.public_key_jwk }
25
+ : { spki: entry.public_key_spki };
26
+ keys.push({
27
+ kid: entry.kid,
28
+ alg: entry.alg,
29
+ publicKey: await importPublicKey(entry.alg, material),
30
+ });
31
+ }
32
+ return new InMemoryKeyStore(keys);
33
+ }
34
+ /** Parse a key-store document from JSON text. */
35
+ export async function parseKeyStore(json) {
36
+ let document;
37
+ try {
38
+ document = JSON.parse(json);
39
+ }
40
+ catch (error) {
41
+ throw new SigningError('SIG_BAD_KEYSTORE', `Key store is not valid JSON: ${error.message}`);
42
+ }
43
+ return loadKeyStoreDocument(document);
44
+ }
45
+ /**
46
+ * Read a key-store document from disk. Node-only by construction — the import
47
+ * is dynamic so that bundling this module for a browser does not pull in `fs`.
48
+ */
49
+ export async function loadKeyStoreFile(path) {
50
+ const { readFileSync } = await import('node:fs');
51
+ let text;
52
+ try {
53
+ text = readFileSync(path, 'utf8');
54
+ }
55
+ catch (error) {
56
+ throw new SigningError('SIG_BAD_KEYSTORE', `Could not read key store '${path}': ${error.message}`);
57
+ }
58
+ return parseKeyStore(text);
59
+ }
60
+ function assertKeyStoreDocument(value) {
61
+ const fail = (message) => {
62
+ throw new SigningError('SIG_BAD_KEYSTORE', message);
63
+ };
64
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
65
+ return fail('Key store must be a JSON object.');
66
+ }
67
+ const doc = value;
68
+ const version = doc['keystore_version'];
69
+ if (typeof version !== 'string')
70
+ return fail('keystore_version must be a string.');
71
+ if (!SUPPORTED_KEYSTORE_VERSIONS.includes(version)) {
72
+ return fail(`Unsupported keystore_version '${version}'; this reader knows ${SUPPORTED_KEYSTORE_VERSIONS.join(', ')}.`);
73
+ }
74
+ const keys = doc['keys'];
75
+ if (!Array.isArray(keys))
76
+ return fail('keys must be an array.');
77
+ const seen = new Set();
78
+ const entries = [];
79
+ for (const [index, raw] of keys.entries()) {
80
+ const at = `keys[${index}]`;
81
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
82
+ return fail(`${at} must be an object.`);
83
+ }
84
+ const entry = raw;
85
+ const kid = entry['kid'];
86
+ if (typeof kid !== 'string' || kid.length === 0)
87
+ return fail(`${at}.kid must be a non-empty string.`);
88
+ // A duplicate kid is refused rather than last-wins: the whole point of a
89
+ // kid is that it names one key, and silently picking one of two makes
90
+ // verification depend on file order.
91
+ if (seen.has(kid))
92
+ return fail(`${at}.kid '${kid}' is declared more than once.`);
93
+ seen.add(kid);
94
+ const alg = entry['alg'];
95
+ if (typeof alg !== 'string' || !isKnownAlgorithm(alg)) {
96
+ return fail(`${at}.alg must be one of ed25519, es256, es384.`);
97
+ }
98
+ const jwk = entry['public_key_jwk'];
99
+ const spki = entry['public_key_spki'];
100
+ const hasJwk = jwk !== undefined;
101
+ const hasSpki = spki !== undefined;
102
+ if (hasJwk === hasSpki) {
103
+ return fail(`${at} must carry exactly one of public_key_jwk or public_key_spki.`);
104
+ }
105
+ if (hasJwk && (typeof jwk !== 'object' || jwk === null || Array.isArray(jwk))) {
106
+ return fail(`${at}.public_key_jwk must be an object.`);
107
+ }
108
+ if (hasSpki && typeof spki !== 'string') {
109
+ return fail(`${at}.public_key_spki must be a base64 string.`);
110
+ }
111
+ entries.push({
112
+ kid,
113
+ alg,
114
+ ...(hasJwk ? { public_key_jwk: jwk } : {}),
115
+ ...(hasSpki ? { public_key_spki: spki } : {}),
116
+ ...(typeof entry['description'] === 'string' ? { description: entry['description'] } : {}),
117
+ });
118
+ }
119
+ return { keystore_version: version, keys: entries };
120
+ }
@@ -0,0 +1,29 @@
1
+ import { type ModuleManifest, type ModuleSignature, type ModuleSignatureVerifier } from '@uwmd/core';
2
+ import type { KeyStore, SigningKey } from './keys.js';
3
+ export interface SignModuleOptions {
4
+ /** ISO 8601 instant to stamp as `signed_at`. Defaults to now. */
5
+ signedAt?: string;
6
+ /**
7
+ * Identity claim to embed. Advisory: a signature proves the key holder
8
+ * asserted this, never that the assertion is true.
9
+ */
10
+ identity?: string;
11
+ }
12
+ /**
13
+ * Sign a module manifest, returning the detached signature.
14
+ *
15
+ * Does not mutate the manifest — use {@link stampModuleSignature}. A manifest
16
+ * that already carries a `signature` can be re-signed: the payload omits the
17
+ * field, so the old signature does not perturb the new one.
18
+ */
19
+ export declare function signModule(manifest: ModuleManifest, key: SigningKey, options?: SignModuleOptions): Promise<ModuleSignature>;
20
+ /** Return a copy of `manifest` carrying `signature`. */
21
+ export declare function stampModuleSignature(manifest: ModuleManifest, signature: ModuleSignature): ModuleManifest;
22
+ /**
23
+ * A {@link ModuleSignatureVerifier} for `verifyModuleSignature` and the async
24
+ * module loaders.
25
+ *
26
+ * Scheme and shape are core's job — it has already run `checkSignatureShape`
27
+ * before reaching here — so this checks the cryptography and nothing else.
28
+ */
29
+ export declare function createModuleSignatureVerifier(store: KeyStore): ModuleSignatureVerifier;
@@ -0,0 +1,56 @@
1
+ // Module manifest signing and verification (RFC 0002, protocol §X.1).
2
+ //
3
+ // Deliberately thin. A module signature is the same act as a block signature
4
+ // over different bytes, so this file is mostly a change of payload — the key
5
+ // handling, the algorithm table, and the actual `subtle.verify` call are the
6
+ // ones `sign.ts` / `verify.ts` already use. Two verifiers would mean two
7
+ // chances to disagree about what a valid signature is.
8
+ import { MODULE_SIGNATURE_SCHEME, moduleSigningPayload, } from '@uwmd/core';
9
+ import { verifyRawSignature } from './verify.js';
10
+ import { signPayload } from './sign.js';
11
+ /**
12
+ * Sign a module manifest, returning the detached signature.
13
+ *
14
+ * Does not mutate the manifest — use {@link stampModuleSignature}. A manifest
15
+ * that already carries a `signature` can be re-signed: the payload omits the
16
+ * field, so the old signature does not perturb the new one.
17
+ */
18
+ export async function signModule(manifest, key, options = {}) {
19
+ const signedAt = options.signedAt ?? new Date().toISOString();
20
+ return {
21
+ scheme: MODULE_SIGNATURE_SCHEME,
22
+ alg: key.alg,
23
+ kid: key.kid,
24
+ sig: await signPayload(moduleSigningPayload(manifest), key),
25
+ signed_at: signedAt,
26
+ ...(options.identity !== undefined ? { identity: options.identity } : {}),
27
+ };
28
+ }
29
+ /** Return a copy of `manifest` carrying `signature`. */
30
+ export function stampModuleSignature(manifest, signature) {
31
+ return { ...manifest, signature };
32
+ }
33
+ /**
34
+ * A {@link ModuleSignatureVerifier} for `verifyModuleSignature` and the async
35
+ * module loaders.
36
+ *
37
+ * Scheme and shape are core's job — it has already run `checkSignatureShape`
38
+ * before reaching here — so this checks the cryptography and nothing else.
39
+ */
40
+ export function createModuleSignatureVerifier(store) {
41
+ return {
42
+ async verify(payload, signature) {
43
+ const verdict = await verifyRawSignature(payload, signature.alg, signature.kid, signature.sig, store);
44
+ if (verdict.ok)
45
+ return { ok: true };
46
+ // `algorithm_mismatch` folds into `bad_signature` at this seam: core's
47
+ // ModuleSignatureVerifier contract has three outcomes, and a key whose
48
+ // curve disagrees with the manifest's claim is a signature that does not
49
+ // verify, not a shape problem core could have caught.
50
+ return {
51
+ ok: false,
52
+ reason: verdict.reason === 'unknown_kid' ? 'unknown_kid' : verdict.reason === 'malformed' ? 'malformed' : 'bad_signature',
53
+ };
54
+ },
55
+ };
56
+ }
package/dist/sign.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { type UWBlock, type UWBlockSignature, type UWReceipt, type UWReceiptSignature } from '@uwmd/core';
2
+ import type { SigningKey } from './keys.js';
3
+ export interface SignBlockOptions {
4
+ /**
5
+ * ISO 8601 instant to stamp as `signed_at`. Defaults to now.
6
+ *
7
+ * Injectable because a signature is only reproducible if its inputs are, and
8
+ * `signed_at` is one of them — conformance fixtures pin it.
9
+ */
10
+ signedAt?: string;
11
+ /**
12
+ * Stamp `content_hash` from the block's current content when it is absent.
13
+ *
14
+ * Off by default, and deliberately so: signing a block whose hash you just
15
+ * computed yourself is a different act from signing a hash somebody else
16
+ * committed to, and quietly doing the former would let a caller sign content
17
+ * that never passed `verifyChain`.
18
+ */
19
+ stampContentHash?: boolean;
20
+ }
21
+ /**
22
+ * Sign a block, returning the detached {@link UWBlockSignature}.
23
+ *
24
+ * The block is not mutated — use {@link stampBlockSignature} to attach the
25
+ * result. Splitting them keeps the byte-preservation invariant honest: the
26
+ * caller decides when the document changes.
27
+ */
28
+ export declare function signBlock(block: UWBlock, key: SigningKey, options?: SignBlockOptions): Promise<UWBlockSignature>;
29
+ /**
30
+ * Return a copy of `block` carrying `signature` (and, when `signBlock` computed
31
+ * one, the `content_hash` it committed to).
32
+ */
33
+ export declare function stampBlockSignature(block: UWBlock, signature: UWBlockSignature, contentHash?: string): UWBlock;
34
+ /**
35
+ * Sign a verification receipt (RFC 0016). The payload is the receipt with
36
+ * `signature: null`, canonicalized — `receiptSigningPayload` in core is the
37
+ * single definition of that, and this package never restates it.
38
+ */
39
+ export declare function signReceipt(receipt: UWReceipt, key: SigningKey): Promise<UWReceiptSignature>;
40
+ /** Attach a receipt signature, returning a new receipt. */
41
+ export declare function stampReceiptSignature(receipt: UWReceipt, signature: UWReceiptSignature): UWReceipt;
42
+ /**
43
+ * Sign arbitrary canonical bytes. Shared by blocks, receipts, and module
44
+ * manifests — the three artifacts differ only in what they canonicalize.
45
+ */
46
+ export declare function signPayload(payload: string, key: SigningKey): Promise<string>;
package/dist/sign.js ADDED
@@ -0,0 +1,81 @@
1
+ // Signing: produce a detached signature over a block or a receipt.
2
+ import { canonicalBlockSigningInput, computeBlockHash, receiptSigningPayload, } from '@uwmd/core';
3
+ import { algorithmParams, subtle } from './algorithms.js';
4
+ import { toBase64Url, utf8 } from './base64.js';
5
+ import { SigningError } from './errors.js';
6
+ /**
7
+ * Sign a block, returning the detached {@link UWBlockSignature}.
8
+ *
9
+ * The block is not mutated — use {@link stampBlockSignature} to attach the
10
+ * result. Splitting them keeps the byte-preservation invariant honest: the
11
+ * caller decides when the document changes.
12
+ */
13
+ export async function signBlock(block, key, options = {}) {
14
+ let contentHash = block.meta.content_hash;
15
+ if (typeof contentHash !== 'string' || contentHash.length === 0) {
16
+ if (!options.stampContentHash) {
17
+ throw new SigningError('SIG_UNSIGNABLE', `Block '${block.meta.section}' has no _meta.content_hash. Stamp one first (or pass stampContentHash) — a signature over an absent hash commits to nothing and validates as INT-05.`);
18
+ }
19
+ contentHash = await computeBlockHash(block);
20
+ }
21
+ const signedAt = options.signedAt ?? new Date().toISOString();
22
+ const payload = canonicalBlockSigningInput({
23
+ content_hash: contentHash,
24
+ section: block.meta.section,
25
+ actor: block.meta.actor,
26
+ timestamp: block.meta.timestamp,
27
+ kid: key.kid,
28
+ signed_at: signedAt,
29
+ });
30
+ return {
31
+ alg: key.alg,
32
+ kid: key.kid,
33
+ sig: await signPayload(payload, key),
34
+ signed_at: signedAt,
35
+ };
36
+ }
37
+ /**
38
+ * Return a copy of `block` carrying `signature` (and, when `signBlock` computed
39
+ * one, the `content_hash` it committed to).
40
+ */
41
+ export function stampBlockSignature(block, signature, contentHash) {
42
+ return {
43
+ ...block,
44
+ meta: {
45
+ ...block.meta,
46
+ ...(contentHash ? { content_hash: contentHash } : {}),
47
+ signature,
48
+ },
49
+ };
50
+ }
51
+ /**
52
+ * Sign a verification receipt (RFC 0016). The payload is the receipt with
53
+ * `signature: null`, canonicalized — `receiptSigningPayload` in core is the
54
+ * single definition of that, and this package never restates it.
55
+ */
56
+ export async function signReceipt(receipt, key) {
57
+ return {
58
+ algorithm: key.alg,
59
+ key_id: key.kid,
60
+ value: await signPayload(receiptSigningPayload(receipt), key),
61
+ };
62
+ }
63
+ /** Attach a receipt signature, returning a new receipt. */
64
+ export function stampReceiptSignature(receipt, signature) {
65
+ return { ...receipt, signature };
66
+ }
67
+ /**
68
+ * Sign arbitrary canonical bytes. Shared by blocks, receipts, and module
69
+ * manifests — the three artifacts differ only in what they canonicalize.
70
+ */
71
+ export async function signPayload(payload, key) {
72
+ const params = algorithmParams(key.alg);
73
+ let bytes;
74
+ try {
75
+ bytes = await subtle().sign(params.signParams, key.privateKey, utf8(payload));
76
+ }
77
+ catch (error) {
78
+ throw new SigningError('SIG_BAD_KEY', `Signing with key '${key.kid}' (${key.alg}) failed: ${error.message}`);
79
+ }
80
+ return toBase64Url(new Uint8Array(bytes));
81
+ }
@@ -0,0 +1,4 @@
1
+ import { type ParsedUWFile, type UWBlock } from '@uwmd/core';
2
+ export declare function makeBlock(sectionId: string, content: Record<string, unknown>, meta?: Partial<UWBlock['meta']>): UWBlock;
3
+ export declare function hashedBlock(sectionId: string, content: Record<string, unknown>, meta?: Partial<UWBlock['meta']>): Promise<UWBlock>;
4
+ export declare function makeFile(sections: Record<string, UWBlock>, superseded?: Record<string, UWBlock[]>): ParsedUWFile;
@@ -0,0 +1,49 @@
1
+ // Block/file fixtures shared by this package's tests.
2
+ //
3
+ // Not exported from `index.ts`: these are test scaffolding, and shipping a
4
+ // block factory in the public API would invite production code to build blocks
5
+ // that never went through the parser.
6
+ import { computeBlockHash } from '@uwmd/core';
7
+ export function makeBlock(sectionId, content, meta = {}) {
8
+ return {
9
+ annotation: { section: sectionId },
10
+ content,
11
+ meta: {
12
+ section: sectionId,
13
+ version: 1,
14
+ superseded: false,
15
+ source: 'manual',
16
+ agent_id: null,
17
+ agent_version: null,
18
+ actor: 'human/jared',
19
+ timestamp: '2026-08-27T00:00:00Z',
20
+ confidence: 'medium',
21
+ human_review_required: false,
22
+ flags: [],
23
+ input_hash: null,
24
+ notes: null,
25
+ ...meta,
26
+ },
27
+ prose: '',
28
+ rawJson: '',
29
+ lineStart: 1,
30
+ lineEnd: 1,
31
+ };
32
+ }
33
+ export async function hashedBlock(sectionId, content, meta = {}) {
34
+ const block = makeBlock(sectionId, content, meta);
35
+ return { ...block, meta: { ...block.meta, content_hash: await computeBlockHash(block) } };
36
+ }
37
+ export function makeFile(sections, superseded = {}) {
38
+ return {
39
+ frontmatter: { asset_class: 'multifamily' },
40
+ sections,
41
+ prose: {},
42
+ pipeline_log: [],
43
+ custom_calculations: [],
44
+ custom_scenarios: [],
45
+ extensions: {},
46
+ superseded,
47
+ raw: '',
48
+ };
49
+ }
@@ -0,0 +1,45 @@
1
+ import { type BlockSigFailure, type BlockSignatureVerifier, type BlockSigVerdict, type ReceiptSignatureVerifier, type UWBlock } from '@uwmd/core';
2
+ import type { KeyStore } from './keys.js';
3
+ /** RFC 0010's `SigVerifyError`, plus the hash check core cannot do alone. */
4
+ export type SigVerifyError = BlockSigFailure | 'content_hash_mismatch';
5
+ export type BlockVerification = {
6
+ ok: true;
7
+ kid: string;
8
+ } | {
9
+ ok: false;
10
+ reason: SigVerifyError;
11
+ kid?: string;
12
+ };
13
+ /**
14
+ * Verify a block's signature end to end: recompute the block's `content_hash`,
15
+ * then check the signature over the canonical signing input.
16
+ *
17
+ * The hash recomputation is the part that makes this meaningful. The signature
18
+ * only commits to a hash *value*; without re-deriving that value from the block
19
+ * in front of you, a tampered block with its original signature and original
20
+ * stamped hash verifies happily.
21
+ */
22
+ export declare function verifyBlockSignature(block: UWBlock, store: KeyStore): Promise<BlockVerification>;
23
+ /**
24
+ * A {@link BlockSignatureVerifier} for `verifyChain(parsed, { signatureVerifier })`.
25
+ *
26
+ * Scoped narrower than {@link verifyBlockSignature} on purpose: `verifyChain`
27
+ * already recomputes every `content_hash` and reports a mismatch as INT-04, so
28
+ * repeating that here would report one tampered block twice under two codes.
29
+ */
30
+ export declare function createBlockSignatureVerifier(store: KeyStore): BlockSignatureVerifier;
31
+ /**
32
+ * A {@link ReceiptSignatureVerifier} for `verifyReceipt(..., { signatureVerifier })`.
33
+ *
34
+ * Without one, a signed receipt verifies as `unverifiable` with RCP-08 — the
35
+ * one advertised receipt feature that shipped unimplemented until this package
36
+ * existed.
37
+ */
38
+ export declare function createReceiptSignatureVerifier(store: KeyStore): ReceiptSignatureVerifier;
39
+ /**
40
+ * The one place a signature is actually checked. Blocks and receipts carry the
41
+ * same three facts under different key names (`alg`/`kid`/`sig` vs
42
+ * `algorithm`/`key_id`/`value`); flattening them here is what keeps the two
43
+ * surfaces from growing two subtly different verifiers.
44
+ */
45
+ export declare function verifyRawSignature(payload: string, alg: string, kid: string, sig: string, store: KeyStore): Promise<BlockSigVerdict>;
package/dist/verify.js ADDED
@@ -0,0 +1,106 @@
1
+ // Verification: check a detached signature against a key store.
2
+ import { blockSigningPayload, computeBlockHash, } from '@uwmd/core';
3
+ import { algorithmParams, isKnownAlgorithm, subtle } from './algorithms.js';
4
+ import { fromBase64Url, utf8 } from './base64.js';
5
+ /**
6
+ * Verify a block's signature end to end: recompute the block's `content_hash`,
7
+ * then check the signature over the canonical signing input.
8
+ *
9
+ * The hash recomputation is the part that makes this meaningful. The signature
10
+ * only commits to a hash *value*; without re-deriving that value from the block
11
+ * in front of you, a tampered block with its original signature and original
12
+ * stamped hash verifies happily.
13
+ */
14
+ export async function verifyBlockSignature(block, store) {
15
+ const signature = block.meta.signature;
16
+ if (!signature)
17
+ return { ok: false, reason: 'malformed' };
18
+ if (!isWellFormed(signature))
19
+ return { ok: false, reason: 'malformed', kid: signature.kid };
20
+ const payload = blockSigningPayload(block);
21
+ if (payload === null)
22
+ return { ok: false, reason: 'malformed', kid: signature.kid };
23
+ const recomputed = await computeBlockHash(block);
24
+ if (recomputed !== block.meta.content_hash) {
25
+ return { ok: false, reason: 'content_hash_mismatch', kid: signature.kid };
26
+ }
27
+ const verdict = await verifyDetached(payload, signature, store);
28
+ return verdict.ok ? { ok: true, kid: signature.kid } : { ...verdict, kid: signature.kid };
29
+ }
30
+ /**
31
+ * A {@link BlockSignatureVerifier} for `verifyChain(parsed, { signatureVerifier })`.
32
+ *
33
+ * Scoped narrower than {@link verifyBlockSignature} on purpose: `verifyChain`
34
+ * already recomputes every `content_hash` and reports a mismatch as INT-04, so
35
+ * repeating that here would report one tampered block twice under two codes.
36
+ */
37
+ export function createBlockSignatureVerifier(store) {
38
+ return {
39
+ async verify(payload, signature) {
40
+ if (!isWellFormed(signature))
41
+ return { ok: false, reason: 'malformed' };
42
+ return verifyDetached(payload, signature, store);
43
+ },
44
+ };
45
+ }
46
+ /**
47
+ * A {@link ReceiptSignatureVerifier} for `verifyReceipt(..., { signatureVerifier })`.
48
+ *
49
+ * Without one, a signed receipt verifies as `unverifiable` with RCP-08 — the
50
+ * one advertised receipt feature that shipped unimplemented until this package
51
+ * existed.
52
+ */
53
+ export function createReceiptSignatureVerifier(store) {
54
+ return {
55
+ async verify(receipt, signedPayload) {
56
+ const signature = receipt.signature;
57
+ if (!signature)
58
+ return false;
59
+ const verdict = await verifyRawSignature(signedPayload, signature.algorithm, signature.key_id, signature.value, store);
60
+ return verdict.ok;
61
+ },
62
+ };
63
+ }
64
+ function verifyDetached(payload, signature, store) {
65
+ return verifyRawSignature(payload, signature.alg, signature.kid, signature.sig, store);
66
+ }
67
+ /**
68
+ * The one place a signature is actually checked. Blocks and receipts carry the
69
+ * same three facts under different key names (`alg`/`kid`/`sig` vs
70
+ * `algorithm`/`key_id`/`value`); flattening them here is what keeps the two
71
+ * surfaces from growing two subtly different verifiers.
72
+ */
73
+ export async function verifyRawSignature(payload, alg, kid, sig, store) {
74
+ if (!isKnownAlgorithm(alg))
75
+ return { ok: false, reason: 'algorithm_mismatch' };
76
+ const key = await store.resolve(kid);
77
+ if (!key)
78
+ return { ok: false, reason: 'unknown_kid' };
79
+ if (key.alg !== alg)
80
+ return { ok: false, reason: 'algorithm_mismatch' };
81
+ let sigBytes;
82
+ try {
83
+ sigBytes = fromBase64Url(sig);
84
+ }
85
+ catch {
86
+ return { ok: false, reason: 'malformed' };
87
+ }
88
+ const params = algorithmParams(alg);
89
+ let ok;
90
+ try {
91
+ ok = await subtle().verify(params.signParams, key.publicKey, sigBytes, utf8(payload));
92
+ }
93
+ catch {
94
+ // Web Crypto throws on a signature of the wrong length for the curve
95
+ // rather than returning false. That is malformed input, not a bad key.
96
+ return { ok: false, reason: 'malformed' };
97
+ }
98
+ return ok ? { ok: true } : { ok: false, reason: 'bad_signature' };
99
+ }
100
+ function isWellFormed(signature) {
101
+ return (typeof signature.alg === 'string' &&
102
+ typeof signature.kid === 'string' &&
103
+ signature.kid.length > 0 &&
104
+ typeof signature.sig === 'string' &&
105
+ signature.sig.length > 0);
106
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@uwmd/signing",
3
+ "version": "0.1.0",
4
+ "description": "Block and receipt signing for UW Markdown (RFC 0010) — Ed25519 / ES256 / ES384 over the normative signing inputs, with a file-backed reference key store. Kept out of @uwmd/core so the library stays crypto-free.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/UWMD-OSP/UW-Markdown.git",
8
+ "directory": "packages/uwmd-signing"
9
+ },
10
+ "homepage": "https://uwmd.org",
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" },
16
+ "./keystore-file": { "import": "./dist/keystore-file.js", "types": "./dist/keystore-file.d.ts" }
17
+ },
18
+ "files": ["dist", "README.md"],
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "vitest run",
22
+ "typecheck:tests": "tsc -p tsconfig.test.json"
23
+ },
24
+ "dependencies": { "@uwmd/core": "1.8.0" },
25
+ "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.4.0", "vitest": "^3.2.6" },
26
+ "engines": { "node": ">=18.4.0" },
27
+ "license": "MIT",
28
+ "keywords": [
29
+ "uwmd",
30
+ "underwriting",
31
+ "commercial-real-estate",
32
+ "signing",
33
+ "ed25519",
34
+ "provenance"
35
+ ]
36
+ }