@docstack/abe 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/LICENSE ADDED
@@ -0,0 +1,10 @@
1
+ Creative Commons Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)
2
+
3
+ Copyright (c) Onyx <hello@onyx.ac> (https://onyx.ac)
4
+
5
+ This work is licensed under the Creative Commons Attribution-ShareAlike 4.0
6
+ International License. You are free to share and adapt this work, including
7
+ commercially, provided you give appropriate attribution and distribute any
8
+ derivative works under the same license.
9
+
10
+ Full legal text: https://creativecommons.org/licenses/by-sa/4.0/legalcode
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ [![npm](https://img.shields.io/npm/v/@docstack/abe)](https://www.npmjs.com/package/@docstack/abe)
2
+ [![Docs](https://img.shields.io/badge/docs-onyx--og.github.io-blue)](https://onyx-og.github.io/docstack/docs/guides/access-scopes)
3
+ [![License](https://img.shields.io/badge/license-CC--BY--SA--4.0-lightgrey)](https://github.com/onyx-og/docstack/blob/main/LICENSE.md)
4
+
5
+ # @docstack/abe
6
+
7
+ **The cryptography behind DocStack access scopes.**
8
+
9
+ CP-ABE, the AC17 scheme, from [rabe](https://github.com/Fraunhofer-AISEC/rabe) compiled to WASM, with the policy normaliser and the authority helpers that mint keys and seal scope content keys. `@docstack/client` depends on it and loads it lazily, only when a stack declares scopes; application code on a device never imports it.
10
+
11
+ An access scope is a named set of content whose encrypted fields seal under one 32-byte content key, and that key is sealed under an attribute policy such as `("role:manager" and "dept:sales") or "clearance:secret"`. A device whose attribute key satisfies the policy opens the scope; one whose key does not holds the same ciphertext and reads `null`. Denial is decryption failure, not a check, so it binds the device owner too.
12
+
13
+ ## Two halves, one module
14
+
15
+ **Authority half**: runs where the application controls it, its server, an admin ceremony, a script. Master keys never belong on end-user devices.
16
+
17
+ ```typescript
18
+ import { setup, keygen, wrapCek } from '@docstack/abe';
19
+
20
+ const { pk, msk } = await setup(); // once; keep msk secret, pk may be public
21
+ const aliceKey = await keygen(msk, ['role:hr', 'dept:people']);
22
+ const sealed = await wrapCek(pk, '"role:hr" or "clearance:exec"', contentKey); // 32 bytes in, opaque blob out
23
+ ```
24
+
25
+ In practice `ClientStack.buildAccessScope({ scopeId, policyString, pk })` from `@docstack/client` calls `wrapCek` for you and returns a complete `~AccessScope` document: a fresh content key sealed under the policy, its key id, and a per-scope canary. Ship that document in an application patch and every device holds it.
26
+
27
+ **Client half**: the one call the engine makes.
28
+
29
+ ```typescript
30
+ import { decryptCek } from '@docstack/abe';
31
+
32
+ const cek = await decryptCek(attributeKey, sealed); // Uint8Array, or null when the policy is not satisfied
33
+ ```
34
+
35
+ ## Policies
36
+
37
+ ```typescript
38
+ import { normalizePolicy, parsePolicy, policyAttributes } from '@docstack/abe';
39
+
40
+ normalizePolicy('"a" and "b" and "c" and "d"'); // '(("a" and "b") and ("c" and "d"))'
41
+ policyAttributes('("role:hr" or "clearance:exec")'); // ['role:hr', 'clearance:exec']
42
+ ```
43
+
44
+ Formulas are monotone: `and`, `or`, parentheses, `"kind:value"` literals. No negation, no code. The scheme's converter needs balanced parentheses and panics on long unbalanced `and` chains, so every sealing call normalises the formula first; `normalizePolicy` throws on `not`.
45
+
46
+ ## Keys and blobs are opaque
47
+
48
+ Every key and ciphertext is a string. rabe serialises 64-bit limbs that do not survive a JavaScript JSON round-trip, so store and transport these blobs verbatim and never parse them.
49
+
50
+ ## Status
51
+
52
+ Experimental cryptography, unaudited. rabe's BN254 backend carries roughly a 100-bit modern security margin; an audit gates any production claim. The reasoning, the alternatives measured and the residual risks are in [ADR-0045](https://github.com/onyx-og/docstack/blob/main/specs/adr/0045-access-control-is-cryptographic-cp-abe-scopes-beside-the-engine.md) and the [threat model](https://onyx-og.github.io/docstack/docs/concepts/access-control/threat-model).
53
+
54
+ ## Building from source
55
+
56
+ The WASM under `src/wasm` is vendored and regenerated by `npm run build:wasm`, which needs the Rust toolchain with the `wasm32-unknown-unknown` target and `wasm-bindgen-cli` matching the version in `rust/Cargo.lock`. `npm run build` compiles the TypeScript and copies the vendored artifacts into `lib/`; `npm test` runs a smoke test through the WASM.
57
+
58
+ ## Documentation
59
+
60
+ - [Scope your data](https://onyx-og.github.io/docstack/docs/guides/access-scopes), the guide
61
+ - [Access control](https://onyx-og.github.io/docstack/docs/concepts/access-control/), the model, its formula language and its limits
62
+ - [API reference](https://onyx-og.github.io/docstack/docs/api/abe/)
63
+
64
+ ## License
65
+
66
+ [CC-BY-SA-4.0](https://github.com/onyx-og/docstack/blob/main/LICENSE.md) · © Onyx AC, LLC. rabe is licensed under the MIT license.
package/lib/index.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ export { normalizePolicy, parsePolicy, policyAttributes } from "./policy.js";
2
+ export type { PolicyNode } from "./policy.js";
3
+ /** Authority: mints the master key pair. Returns opaque `pk` (public) and `msk` (SECRET) blobs. */
4
+ export declare const setup: () => Promise<{
5
+ pk: string;
6
+ msk: string;
7
+ }>;
8
+ /**
9
+ * Authority: issues a user's attribute secret key — their attributes embedded
10
+ * in the key material itself. The returned blob is the `attributeKey` the
11
+ * consumer's infrastructure hands to that user's devices (spec 02 §4).
12
+ */
13
+ export declare const keygen: (msk: string, attributes: string[]) => Promise<string>;
14
+ /**
15
+ * Authority: seals a scope's 32-byte CEK under a policy formula. The formula
16
+ * is normalized first (balanced parenthesization — rabe's converter panics on
17
+ * long unbalanced AND-chains), so authors write it naturally.
18
+ */
19
+ export declare const wrapCek: (pk: string, policy: string, cek: Uint8Array) => Promise<string>;
20
+ /**
21
+ * Client: attempts a scope's CEK with the session's attribute key.
22
+ * `null` means the key does not satisfy the scope's policy — access denial as
23
+ * decryption failure, the whole point of the architecture.
24
+ */
25
+ export declare const decryptCek: (attributeKey: string, wrappedCek: string) => Promise<Uint8Array | null>;
package/lib/index.js ADDED
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @docstack/abe — CP-ABE (AC17, the FAME scheme) for DocStack access scopes.
3
+ *
4
+ * rabe (Fraunhofer AISEC) compiled to WASM; gate ruling ADR-0045. Every key
5
+ * and ciphertext is an OPAQUE string: rabe-bn serializes u64 limbs that do not
6
+ * survive a JavaScript JSON round-trip (found the hard way in the spike), so
7
+ * callers store and transport these blobs verbatim and never parse them.
8
+ *
9
+ * Two halves share the module because they share the WASM:
10
+ * - **Authority half** (`setup`, `keygen`, `wrapCek`): runs where the
11
+ * application controls it — its server, an admin ceremony, later the
12
+ * docstack-server package. Master keys never belong on end-user devices.
13
+ * - **Client half** (`decryptCek`): the only call @docstack/client makes.
14
+ * Denial is `null` — the mathematics failing IS the access decision.
15
+ *
16
+ * EXPERIMENTAL CRYPTOGRAPHY: unaudited (rabe-bn is a fork of an old zcash
17
+ * BN254 crate, ~100-bit modern margin). An audit gates any production claim.
18
+ */
19
+ import { ensureAbe } from "./runtime.js";
20
+ import { normalizePolicy } from "./policy.js";
21
+ export { normalizePolicy, parsePolicy, policyAttributes } from "./policy.js";
22
+ /** Authority: mints the master key pair. Returns opaque `pk` (public) and `msk` (SECRET) blobs. */
23
+ export const setup = async () => {
24
+ const abe = await ensureAbe();
25
+ const bundle = JSON.parse(abe.setup());
26
+ return { pk: bundle.pk, msk: bundle.msk };
27
+ };
28
+ /**
29
+ * Authority: issues a user's attribute secret key — their attributes embedded
30
+ * in the key material itself. The returned blob is the `attributeKey` the
31
+ * consumer's infrastructure hands to that user's devices (spec 02 §4).
32
+ */
33
+ export const keygen = async (msk, attributes) => {
34
+ if (!attributes.length)
35
+ throw new Error("keygen requires at least one attribute.");
36
+ const abe = await ensureAbe();
37
+ return abe.cp_keygen(msk, JSON.stringify(attributes));
38
+ };
39
+ /**
40
+ * Authority: seals a scope's 32-byte CEK under a policy formula. The formula
41
+ * is normalized first (balanced parenthesization — rabe's converter panics on
42
+ * long unbalanced AND-chains), so authors write it naturally.
43
+ */
44
+ export const wrapCek = async (pk, policy, cek) => {
45
+ if (cek.length !== 32)
46
+ throw new Error(`wrapCek seals 32-byte CEKs; got ${cek.length} bytes.`);
47
+ const abe = await ensureAbe();
48
+ return abe.cp_encrypt(pk, normalizePolicy(policy), cek);
49
+ };
50
+ /**
51
+ * Client: attempts a scope's CEK with the session's attribute key.
52
+ * `null` means the key does not satisfy the scope's policy — access denial as
53
+ * decryption failure, the whole point of the architecture.
54
+ */
55
+ export const decryptCek = async (attributeKey, wrappedCek) => {
56
+ const abe = await ensureAbe();
57
+ try {
58
+ return abe.cp_decrypt(attributeKey, wrappedCek);
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The attribute-policy language and its normalizer.
3
+ *
4
+ * DocStack's one access-control language (ADR-0045, spec 02 §1): quoted
5
+ * attributes composed with `and`/`or` and parentheses — monotone by
6
+ * construction, no negation, no data-dependence. This module is the ONLY path
7
+ * a policy string takes into the ABE layer: rabe's MSP converter panics on
8
+ * unparenthesized AND-chains of length ≥ 3 (characterized in
9
+ * `spikes/abe/bench/rabe-probe.ts`), so every formula is parsed here and
10
+ * re-emitted as a fully parenthesized, BALANCED binary tree before it reaches
11
+ * the scheme. Authors write formulas naturally; the wire form is canonical.
12
+ */
13
+ export type PolicyNode = {
14
+ kind: "attr";
15
+ name: string;
16
+ } | {
17
+ kind: "op";
18
+ op: "and" | "or";
19
+ children: PolicyNode[];
20
+ };
21
+ /** Recursive-descent parse; `and` binds tighter than `or` (the conventional precedence). */
22
+ export declare const parsePolicy: (policy: string) => PolicyNode;
23
+ /**
24
+ * Parses and re-emits a policy in canonical form: attributes quoted, every
25
+ * operator application parenthesized, same-op chains balanced. Throws on
26
+ * anything outside the language — the loud refusal is the API.
27
+ */
28
+ export declare const normalizePolicy: (policy: string) => string;
29
+ /** The attribute names a formula mentions, in first-appearance order. */
30
+ export declare const policyAttributes: (policy: string) => string[];
package/lib/policy.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The attribute-policy language and its normalizer.
3
+ *
4
+ * DocStack's one access-control language (ADR-0045, spec 02 §1): quoted
5
+ * attributes composed with `and`/`or` and parentheses — monotone by
6
+ * construction, no negation, no data-dependence. This module is the ONLY path
7
+ * a policy string takes into the ABE layer: rabe's MSP converter panics on
8
+ * unparenthesized AND-chains of length ≥ 3 (characterized in
9
+ * `spikes/abe/bench/rabe-probe.ts`), so every formula is parsed here and
10
+ * re-emitted as a fully parenthesized, BALANCED binary tree before it reaches
11
+ * the scheme. Authors write formulas naturally; the wire form is canonical.
12
+ */
13
+ const tokenize = (policy) => {
14
+ const tokens = [];
15
+ let i = 0;
16
+ while (i < policy.length) {
17
+ const ch = policy[i];
18
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
19
+ i++;
20
+ continue;
21
+ }
22
+ if (ch === "(") {
23
+ tokens.push({ kind: "open" });
24
+ i++;
25
+ continue;
26
+ }
27
+ if (ch === ")") {
28
+ tokens.push({ kind: "close" });
29
+ i++;
30
+ continue;
31
+ }
32
+ if (ch === '"') {
33
+ const end = policy.indexOf('"', i + 1);
34
+ if (end === -1)
35
+ throw new Error(`Policy has an unterminated quoted attribute at position ${i}.`);
36
+ const name = policy.slice(i + 1, end);
37
+ if (!name.trim())
38
+ throw new Error("Policy contains an empty attribute name.");
39
+ tokens.push({ kind: "attr", name });
40
+ i = end + 1;
41
+ continue;
42
+ }
43
+ // A bare word: and / or, case-insensitive. Anything else is refused
44
+ // loudly — including every negation spelling, which the monotone
45
+ // language deliberately lacks.
46
+ const match = /^[A-Za-z_][A-Za-z0-9_:-]*/.exec(policy.slice(i));
47
+ if (!match)
48
+ throw new Error(`Unexpected character '${ch}' in policy at position ${i}.`);
49
+ const word = match[0];
50
+ const lower = word.toLowerCase();
51
+ if (lower === "and")
52
+ tokens.push({ kind: "and" });
53
+ else if (lower === "or")
54
+ tokens.push({ kind: "or" });
55
+ else if (lower === "not" || lower === "nand" || lower === "nor") {
56
+ throw new Error(`Policy operator '${word}' is not part of the monotone language: no negation exists. Model the positive attribute instead.`);
57
+ }
58
+ else {
59
+ throw new Error(`Unquoted word '${word}' in policy: attributes must be double-quoted, e.g. "role:manager".`);
60
+ }
61
+ i += word.length;
62
+ }
63
+ return tokens;
64
+ };
65
+ /** Recursive-descent parse; `and` binds tighter than `or` (the conventional precedence). */
66
+ export const parsePolicy = (policy) => {
67
+ const tokens = tokenize(policy);
68
+ let position = 0;
69
+ const peek = () => tokens[position];
70
+ const take = () => tokens[position++];
71
+ const primary = () => {
72
+ const token = take();
73
+ if (!token)
74
+ throw new Error("Policy ended where an attribute or '(' was expected.");
75
+ if (token.kind === "attr")
76
+ return { kind: "attr", name: token.name };
77
+ if (token.kind === "open") {
78
+ const node = orExpr();
79
+ const close = take();
80
+ if (!close || close.kind !== "close")
81
+ throw new Error("Policy has an unbalanced '('.");
82
+ return node;
83
+ }
84
+ throw new Error("Policy has an operator where an attribute or '(' was expected.");
85
+ };
86
+ const andExpr = () => {
87
+ const children = [primary()];
88
+ while (peek()?.kind === "and") {
89
+ take();
90
+ children.push(primary());
91
+ }
92
+ return children.length === 1 ? children[0] : { kind: "op", op: "and", children };
93
+ };
94
+ const orExpr = () => {
95
+ const children = [andExpr()];
96
+ while (peek()?.kind === "or") {
97
+ take();
98
+ children.push(andExpr());
99
+ }
100
+ return children.length === 1 ? children[0] : { kind: "op", op: "or", children };
101
+ };
102
+ const root = orExpr();
103
+ if (position !== tokens.length)
104
+ throw new Error("Policy has trailing content after a complete formula (an unbalanced ')' or a missing operator).");
105
+ return root;
106
+ };
107
+ /** Flattens nested same-op nodes so chains balance across author parenthesization. */
108
+ const flatten = (node) => {
109
+ if (node.kind === "attr")
110
+ return node;
111
+ const children = [];
112
+ for (const child of node.children.map(flatten)) {
113
+ if (child.kind === "op" && child.op === node.op)
114
+ children.push(...child.children);
115
+ else
116
+ children.push(child);
117
+ }
118
+ return { kind: "op", op: node.op, children };
119
+ };
120
+ /** Emits a chain as a balanced binary tree — the shape rabe's converter accepts at any length. */
121
+ const emitBalanced = (nodes, op) => {
122
+ if (nodes.length === 1)
123
+ return emit(nodes[0]);
124
+ const mid = Math.ceil(nodes.length / 2);
125
+ return `(${emitBalanced(nodes.slice(0, mid), op)} ${op} ${emitBalanced(nodes.slice(mid), op)})`;
126
+ };
127
+ const emit = (node) => node.kind === "attr" ? `"${node.name}"` : emitBalanced(node.children, node.op);
128
+ /**
129
+ * Parses and re-emits a policy in canonical form: attributes quoted, every
130
+ * operator application parenthesized, same-op chains balanced. Throws on
131
+ * anything outside the language — the loud refusal is the API.
132
+ */
133
+ export const normalizePolicy = (policy) => emit(flatten(parsePolicy(policy)));
134
+ /** The attribute names a formula mentions, in first-appearance order. */
135
+ export const policyAttributes = (policy) => {
136
+ const seen = new Set();
137
+ const walk = (node) => {
138
+ if (node.kind === "attr")
139
+ seen.add(node.name);
140
+ else
141
+ node.children.forEach(walk);
142
+ };
143
+ walk(parsePolicy(policy));
144
+ return [...seen];
145
+ };
@@ -0,0 +1,7 @@
1
+ export type AbeApi = {
2
+ setup(): string;
3
+ cp_keygen(mskJson: string, attrsJson: string): string;
4
+ cp_encrypt(pkJson: string, policy: string, plaintext: Uint8Array): string;
5
+ cp_decrypt(skJson: string, ctJson: string): Uint8Array;
6
+ };
7
+ export declare const ensureAbe: () => Promise<AbeApi>;
package/lib/runtime.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Lazy WASM instantiation. The module bytes ride the bundle base64-embedded
3
+ * (no fetch, no asset path — every bundler and the browser test harness see
4
+ * plain JS), but nothing compiles until the first ABE call: a stack that never
5
+ * touches scopes pays bytes, not startup. Instantiation is ASYNC on purpose —
6
+ * Chrome caps synchronous WebAssembly compilation on the main thread, so
7
+ * `initSync` over a 600 KB module is a browser refusal waiting to happen.
8
+ */
9
+ import init, * as glue from "./wasm/glue.js";
10
+ import { WASM_BASE64 } from "./wasm/bytes.js";
11
+ let ready = null;
12
+ const decode = (b64) => {
13
+ if (typeof Buffer !== "undefined")
14
+ return new Uint8Array(Buffer.from(b64, "base64"));
15
+ const binary = atob(b64);
16
+ const bytes = new Uint8Array(binary.length);
17
+ for (let i = 0; i < binary.length; i++)
18
+ bytes[i] = binary.charCodeAt(i);
19
+ return bytes;
20
+ };
21
+ export const ensureAbe = () => {
22
+ if (!ready) {
23
+ ready = init({ module_or_path: decode(WASM_BASE64) }).then(() => glue);
24
+ }
25
+ return ready;
26
+ };
@@ -0,0 +1 @@
1
+ export declare const WASM_BASE64: string;