@uwmd/signing 0.1.0 → 0.2.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.
@@ -0,0 +1,46 @@
1
+ import type { CapabilityVerifier } from '@uwmd/core';
2
+ import type { KeyStore, SigningKey } from './keys.js';
3
+ /** The claims a capability token carries (Protocol §XIV.2). */
4
+ export interface CapabilityTokenClaims {
5
+ /** Coordinator identity (informational; not verified beyond the signature). */
6
+ iss: string;
7
+ /** The RFC 0031 actor source being authorized (format spec §2.6 grammar). */
8
+ sub: string;
9
+ /** MUST be `uwmd-edit`. */
10
+ aud: string;
11
+ /** Frontmatter `deal_id` binding. */
12
+ deal: string;
13
+ /** Section ids the token may write (`_frontmatter` for frontmatter ops). Absent = unconstrained. */
14
+ sections?: string[];
15
+ /** DealStage values the token is valid at. Absent = unconstrained. */
16
+ stages?: string[];
17
+ /** EditOperation kinds permitted. Absent = unconstrained. */
18
+ ops?: string[];
19
+ /** Issued at (unix seconds). */
20
+ iat: number;
21
+ /** Expiry (unix seconds). */
22
+ exp: number;
23
+ /** Unique token id, recorded in the written block's notes as `capability:<jti>`. */
24
+ jti: string;
25
+ }
26
+ /** The fixed JWT audience for edit authorization. */
27
+ export declare const CAPABILITY_AUDIENCE: "uwmd-edit";
28
+ export interface CapabilityVerifierOptions {
29
+ /** Clock override, unix seconds. Tests and replay; defaults to wall time. */
30
+ now?: () => number;
31
+ }
32
+ /**
33
+ * Build the reference {@link CapabilityVerifier} over a {@link KeyStore}.
34
+ *
35
+ * Rejection order follows §XIV.3: structure and signature first (`malformed`,
36
+ * `unknown_kid`, `bad_signature`), then time, then each scope claim, then the
37
+ * `sub` ↔ `_meta.source` binding. Absent `sections`/`stages`/`ops` claims are
38
+ * unconstrained; every other claim is required and its absence is `malformed`.
39
+ */
40
+ export declare function createCapabilityVerifier(store: KeyStore, options?: CapabilityVerifierOptions): CapabilityVerifier;
41
+ /**
42
+ * Sign a capability token: JWS Compact over the JSON claims, JOSE alg names
43
+ * in the header. A coordinator-side convenience (and the fixture generator
44
+ * for the conformance suite) — verification does not require it.
45
+ */
46
+ export declare function signCapabilityToken(claims: CapabilityTokenClaims, key: SigningKey): Promise<string>;
@@ -0,0 +1,151 @@
1
+ // Capability tokens — the reference CapabilityVerifier (Protocol §XIV, RFC 0011).
2
+ //
3
+ // A capability token is a JWS Compact–encoded JWT a coordinator signs:
4
+ // "this actor may write these sections at these stages for this deal, until
5
+ // this time." Core defines the `CapabilityVerifier` interface and the editor
6
+ // hook (`EditOptions.capabilityVerifier`); this module supplies the crypto —
7
+ // JWT decode, JOSE-alg mapping onto the §V.11 algorithm shortlist, and
8
+ // signature verification over the same KeyStore block signatures use. One
9
+ // key-distribution story, not two.
10
+ //
11
+ // Scope discipline mirrors the editor's: this verifier checks the token
12
+ // against the edit context it is handed. The static §V.3 policy check is the
13
+ // editor's job and runs regardless — a token narrows authority, never widens.
14
+ import { parseActorSource } from '@uwmd/core';
15
+ import { fromBase64Url, toBase64Url, utf8 } from './base64.js';
16
+ import { signPayload } from './sign.js';
17
+ import { verifyRawSignature } from './verify.js';
18
+ /** The fixed JWT audience for edit authorization. */
19
+ export const CAPABILITY_AUDIENCE = 'uwmd-edit';
20
+ // JOSE `alg` header names for the §V.11 shortlist. The token is a JWT, so its
21
+ // header speaks JOSE; the KeyStore speaks the protocol's own names.
22
+ const JOSE_TO_UW = Object.freeze({
23
+ EdDSA: 'ed25519',
24
+ ES256: 'es256',
25
+ ES384: 'es384',
26
+ });
27
+ const UW_TO_JOSE = Object.freeze({
28
+ ed25519: 'EdDSA',
29
+ es256: 'ES256',
30
+ es384: 'ES384',
31
+ });
32
+ /**
33
+ * Build the reference {@link CapabilityVerifier} over a {@link KeyStore}.
34
+ *
35
+ * Rejection order follows §XIV.3: structure and signature first (`malformed`,
36
+ * `unknown_kid`, `bad_signature`), then time, then each scope claim, then the
37
+ * `sub` ↔ `_meta.source` binding. Absent `sections`/`stages`/`ops` claims are
38
+ * unconstrained; every other claim is required and its absence is `malformed`.
39
+ */
40
+ export function createCapabilityVerifier(store, options = {}) {
41
+ const now = options.now ?? (() => Math.floor(Date.now() / 1000));
42
+ return {
43
+ async verify(token, ctx) {
44
+ const decoded = decodeCompact(token);
45
+ if (!decoded)
46
+ return reject('malformed');
47
+ const { header, claims, signingInput, signature } = decoded;
48
+ const alg = JOSE_TO_UW[header.alg ?? ''];
49
+ if (!alg || typeof header.kid !== 'string' || header.kid.length === 0) {
50
+ return reject('malformed');
51
+ }
52
+ const sigVerdict = await verifyRawSignature(signingInput, alg, header.kid, signature, store);
53
+ if (!sigVerdict.ok) {
54
+ if (sigVerdict.reason === 'unknown_kid')
55
+ return reject('unknown_kid');
56
+ if (sigVerdict.reason === 'bad_signature')
57
+ return reject('bad_signature');
58
+ return reject('malformed'); // algorithm_mismatch / malformed input
59
+ }
60
+ if (!hasRequiredClaims(claims))
61
+ return reject('malformed');
62
+ // `sub` outside the RFC 0031 actor grammar can never match a governed
63
+ // `_meta.source`; report it as a malformed token, not a mismatch.
64
+ if (parseActorSource(claims.sub).kind === 'invalid')
65
+ return reject('malformed');
66
+ const at = now();
67
+ if (at < claims.iat)
68
+ return reject('not_yet_valid');
69
+ if (at >= claims.exp)
70
+ return reject('expired');
71
+ if (claims.aud !== CAPABILITY_AUDIENCE)
72
+ return reject('wrong_audience');
73
+ if (claims.deal !== ctx.deal_id)
74
+ return reject('wrong_deal');
75
+ if (claims.sections && !claims.sections.includes(ctx.section))
76
+ return reject('wrong_section');
77
+ if (claims.stages) {
78
+ // A file with no declared stage fails a stage-constrained token: the
79
+ // token asserts a stage scope the file cannot demonstrate.
80
+ if (ctx.stage === null || !claims.stages.includes(ctx.stage))
81
+ return reject('wrong_stage');
82
+ }
83
+ if (claims.ops && !claims.ops.includes(ctx.op))
84
+ return reject('wrong_op');
85
+ if (claims.sub !== ctx.source)
86
+ return reject('sub_mismatch');
87
+ return { ok: true, sub: claims.sub, jti: claims.jti };
88
+ },
89
+ };
90
+ }
91
+ /**
92
+ * Sign a capability token: JWS Compact over the JSON claims, JOSE alg names
93
+ * in the header. A coordinator-side convenience (and the fixture generator
94
+ * for the conformance suite) — verification does not require it.
95
+ */
96
+ export async function signCapabilityToken(claims, key) {
97
+ const header = { alg: UW_TO_JOSE[key.alg], kid: key.kid, typ: 'JWT' };
98
+ const signingInput = `${b64Json(header)}.${b64Json(claims)}`;
99
+ const signature = await signPayload(signingInput, key);
100
+ return `${signingInput}.${signature}`;
101
+ }
102
+ // ─── Internals ───────────────────────────────────────────────────────────────
103
+ function reject(reason) {
104
+ return { ok: false, reason };
105
+ }
106
+ function b64Json(value) {
107
+ return toBase64Url(utf8(JSON.stringify(value)));
108
+ }
109
+ function decodeCompact(token) {
110
+ const parts = token.split('.');
111
+ if (parts.length !== 3)
112
+ return null;
113
+ const [h, p, signature] = parts;
114
+ if (!h || !p || !signature)
115
+ return null;
116
+ let header;
117
+ let claims;
118
+ try {
119
+ header = JSON.parse(new TextDecoder().decode(fromBase64Url(h)));
120
+ claims = JSON.parse(new TextDecoder().decode(fromBase64Url(p)));
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ if (!isObject(header) || !isObject(claims))
126
+ return null;
127
+ return {
128
+ header: header,
129
+ claims: claims,
130
+ signingInput: `${h}.${p}`,
131
+ signature,
132
+ };
133
+ }
134
+ function isObject(v) {
135
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
136
+ }
137
+ function hasRequiredClaims(claims) {
138
+ return (typeof claims.iss === 'string' &&
139
+ typeof claims.sub === 'string' &&
140
+ typeof claims.aud === 'string' &&
141
+ typeof claims.deal === 'string' &&
142
+ typeof claims.iat === 'number' && Number.isFinite(claims.iat) &&
143
+ typeof claims.exp === 'number' && Number.isFinite(claims.exp) &&
144
+ typeof claims.jti === 'string' && claims.jti.length > 0 &&
145
+ (claims.sections === undefined || isStringArray(claims.sections)) &&
146
+ (claims.stages === undefined || isStringArray(claims.stages)) &&
147
+ (claims.ops === undefined || isStringArray(claims.ops)));
148
+ }
149
+ function isStringArray(v) {
150
+ return Array.isArray(v) && v.every((x) => typeof x === 'string');
151
+ }
package/dist/index.d.ts CHANGED
@@ -10,7 +10,9 @@ export { signBlock, stampBlockSignature, signReceipt, stampReceiptSignature } fr
10
10
  export type { SignBlockOptions } from './sign.js';
11
11
  export { verifyBlockSignature, createBlockSignatureVerifier, createReceiptSignatureVerifier, } from './verify.js';
12
12
  export type { BlockVerification, SigVerifyError } from './verify.js';
13
+ export { createCapabilityVerifier, signCapabilityToken, CAPABILITY_AUDIENCE, } from './capability.js';
14
+ export type { CapabilityTokenClaims, CapabilityVerifierOptions } from './capability.js';
13
15
  export { signModule, stampModuleSignature, createModuleSignatureVerifier } from './modules.js';
14
16
  export type { SignModuleOptions } from './modules.js';
15
17
  export declare const SIGNING_PACKAGE_NAME: "@uwmd/signing";
16
- export declare const SIGNING_VERSION = "0.1.0";
18
+ export declare const SIGNING_VERSION = "0.2.0";
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ export { InMemoryKeyStore, importPublicKey, importPrivateKey, generateSigningKey
18
18
  export { loadKeyStoreDocument, parseKeyStore, loadKeyStoreFile, } from './keystore-file.js';
19
19
  export { signBlock, stampBlockSignature, signReceipt, stampReceiptSignature } from './sign.js';
20
20
  export { verifyBlockSignature, createBlockSignatureVerifier, createReceiptSignatureVerifier, } from './verify.js';
21
+ export { createCapabilityVerifier, signCapabilityToken, CAPABILITY_AUDIENCE, } from './capability.js';
21
22
  export { signModule, stampModuleSignature, createModuleSignatureVerifier } from './modules.js';
22
23
  export const SIGNING_PACKAGE_NAME = '@uwmd/signing';
23
- export const SIGNING_VERSION = '0.1.0';
24
+ export const SIGNING_VERSION = '0.2.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uwmd/signing",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "repository": {
6
6
  "type": "git",
@@ -21,7 +21,7 @@
21
21
  "test": "vitest run",
22
22
  "typecheck:tests": "tsc -p tsconfig.test.json"
23
23
  },
24
- "dependencies": { "@uwmd/core": "1.8.0" },
24
+ "dependencies": { "@uwmd/core": "1.9.0" },
25
25
  "devDependencies": { "@types/node": "^20.0.0", "typescript": "^5.4.0", "vitest": "^3.2.6" },
26
26
  "engines": { "node": ">=18.4.0" },
27
27
  "license": "MIT",