@sudp-protocol/authorizer 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.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * `@sudp-protocol/authorizer/webauthn` — WebAuthn adapter for the Authorizer side.
3
+ *
4
+ * The core `@sudp-protocol/authorizer` package is intentionally **signer-agnostic**:
5
+ * it accepts a 32-byte `userKey` (= `y_c`) and does not care how the
6
+ * Authorizer obtained it. This subpath is the WebAuthn-specific glue:
7
+ *
8
+ * - it runs HKDF over the raw PRF extension output to produce `y_c`;
9
+ * - it shapes a `PublicKeyCredential` assertion into the wire form the
10
+ * custodian expects.
11
+ *
12
+ * Other realisations (Yubikey static, secure-enclave, HSM, mock for tests)
13
+ * live in their own adapters and never touch this file.
14
+ */
15
+ /**
16
+ * Default HKDF `info` for WebAuthn PRF → userKey derivation. Identifies
17
+ * this specific adapter on the wire.
18
+ */
19
+ export declare const DEFAULT_PRF_INFO: Uint8Array<ArrayBufferLike>;
20
+ /**
21
+ * Options for {@link prfToUserKey}.
22
+ */
23
+ export interface PrfToUserKeyOptions {
24
+ /**
25
+ * HKDF `info` parameter. Defaults to {@link DEFAULT_PRF_INFO}; deployments
26
+ * that need to differentiate multiple WebAuthn-PRF surfaces under the same
27
+ * custodian may override it.
28
+ */
29
+ readonly info?: Uint8Array;
30
+ /**
31
+ * HKDF `salt` parameter. Defaults to a 32-byte zero salt (extract-only
32
+ * HKDF when the IKM is already uniform, per RFC 5869 §3.1).
33
+ */
34
+ readonly salt?: Uint8Array;
35
+ }
36
+ /**
37
+ * Derive a 32-byte `userKey` (= `y_c`) from the WebAuthn PRF extension's
38
+ * raw first-output bytes.
39
+ *
40
+ * y_c = HKDF-SHA-256(prfOutput, salt, info)
41
+ *
42
+ * The `info` string is what makes this adapter distinct from other ways of
43
+ * producing `y_c` — picking the same `info` in a different authenticator
44
+ * would lock you to compatible wire bytes.
45
+ */
46
+ export declare function prfToUserKey(prfOutput: Uint8Array, options?: PrfToUserKeyOptions): Promise<Uint8Array>;
47
+ /**
48
+ * Wire-shape of a WebAuthn assertion that the custodian's WebAuthn
49
+ * `Authenticator` realisation can verify. Field names match the daemon's
50
+ * expected wire format.
51
+ */
52
+ export interface AssertionWire {
53
+ readonly credentialId: Uint8Array;
54
+ readonly authenticatorData: Uint8Array;
55
+ readonly clientDataJSON: Uint8Array;
56
+ readonly signature: Uint8Array;
57
+ }
58
+ /**
59
+ * Extract the four fields a custodian needs from a `PublicKeyCredential`
60
+ * assertion.
61
+ *
62
+ * Note: this returns raw bytes. Callers that need base64url wire
63
+ * encoding can run the fields through `bytesToB64Url` from the core
64
+ * package.
65
+ */
66
+ export declare function assertionToWire(assertion: PublicKeyCredential): AssertionWire;
67
+ //# sourceMappingURL=webauthn.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webauthn.d.ts","sourceRoot":"","sources":["../src/webauthn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH;;;GAGG;AACH,eAAO,MAAM,gBAAgB,6BAAuC,CAAC;AAErE;;GAEG;AACH,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,UAAU,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,IAAI,CAAC,EAAE,UAAU,CAAC;CAC5B;AAID;;;;;;;;;GASG;AACH,wBAAsB,YAAY,CAChC,SAAS,EAAE,UAAU,EACrB,OAAO,CAAC,EAAE,mBAAmB,GAC5B,OAAO,CAAC,UAAU,CAAC,CAqBrB;AAED;;;;GAIG;AACH,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,YAAY,EAAE,UAAU,CAAC;IAClC,QAAQ,CAAC,iBAAiB,EAAE,UAAU,CAAC;IACvC,QAAQ,CAAC,cAAc,EAAE,UAAU,CAAC;IACpC,QAAQ,CAAC,SAAS,EAAE,UAAU,CAAC;CAChC;AAED;;;;;;;GAOG;AACH,wBAAgB,eAAe,CAC7B,SAAS,EAAE,mBAAmB,GAC7B,aAAa,CAQf"}
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `@sudp-protocol/authorizer/webauthn` — WebAuthn adapter for the Authorizer side.
3
+ *
4
+ * The core `@sudp-protocol/authorizer` package is intentionally **signer-agnostic**:
5
+ * it accepts a 32-byte `userKey` (= `y_c`) and does not care how the
6
+ * Authorizer obtained it. This subpath is the WebAuthn-specific glue:
7
+ *
8
+ * - it runs HKDF over the raw PRF extension output to produce `y_c`;
9
+ * - it shapes a `PublicKeyCredential` assertion into the wire form the
10
+ * custodian expects.
11
+ *
12
+ * Other realisations (Yubikey static, secure-enclave, HSM, mock for tests)
13
+ * live in their own adapters and never touch this file.
14
+ */
15
+ import { utf8 } from "./bytes.js";
16
+ /**
17
+ * Default HKDF `info` for WebAuthn PRF → userKey derivation. Identifies
18
+ * this specific adapter on the wire.
19
+ */
20
+ export const DEFAULT_PRF_INFO = utf8("sudp/v1/webauthn-prf-userkey");
21
+ const DEFAULT_PRF_SALT = new Uint8Array(32);
22
+ /**
23
+ * Derive a 32-byte `userKey` (= `y_c`) from the WebAuthn PRF extension's
24
+ * raw first-output bytes.
25
+ *
26
+ * y_c = HKDF-SHA-256(prfOutput, salt, info)
27
+ *
28
+ * The `info` string is what makes this adapter distinct from other ways of
29
+ * producing `y_c` — picking the same `info` in a different authenticator
30
+ * would lock you to compatible wire bytes.
31
+ */
32
+ export async function prfToUserKey(prfOutput, options) {
33
+ const info = options?.info ?? DEFAULT_PRF_INFO;
34
+ const salt = options?.salt ?? DEFAULT_PRF_SALT;
35
+ const km = await crypto.subtle.importKey("raw", prfOutput, "HKDF", false, ["deriveBits"]);
36
+ const bits = await crypto.subtle.deriveBits({
37
+ name: "HKDF",
38
+ hash: "SHA-256",
39
+ salt: salt,
40
+ info: info,
41
+ }, km, 256);
42
+ return new Uint8Array(bits);
43
+ }
44
+ /**
45
+ * Extract the four fields a custodian needs from a `PublicKeyCredential`
46
+ * assertion.
47
+ *
48
+ * Note: this returns raw bytes. Callers that need base64url wire
49
+ * encoding can run the fields through `bytesToB64Url` from the core
50
+ * package.
51
+ */
52
+ export function assertionToWire(assertion) {
53
+ const r = assertion.response;
54
+ return {
55
+ credentialId: new Uint8Array(assertion.rawId),
56
+ authenticatorData: new Uint8Array(r.authenticatorData),
57
+ clientDataJSON: new Uint8Array(r.clientDataJSON),
58
+ signature: new Uint8Array(r.signature),
59
+ };
60
+ }
61
+ //# sourceMappingURL=webauthn.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webauthn.js","sourceRoot":"","sources":["../src/webauthn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,IAAI,EAAE,MAAM,YAAY,CAAC;AAElC;;;GAGG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC,8BAA8B,CAAC,CAAC;AAmBrE,MAAM,gBAAgB,GAAG,IAAI,UAAU,CAAC,EAAE,CAAC,CAAC;AAE5C;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,YAAY,CAChC,SAAqB,EACrB,OAA6B;IAE7B,MAAM,IAAI,GAAG,OAAO,EAAE,IAAI,IAAI,gBAAgB,CAAC;IAC/C,MAAM,IAAI,GAAG,OAAO,EAAE,IAAI,IAAI,gBAAgB,CAAC;IAC/C,MAAM,EAAE,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,SAAS,CACtC,KAAK,EACL,SAAmC,EACnC,MAAM,EACN,KAAK,EACL,CAAC,YAAY,CAAC,CACf,CAAC;IACF,MAAM,IAAI,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,UAAU,CACzC;QACE,IAAI,EAAE,MAAM;QACZ,IAAI,EAAE,SAAS;QACf,IAAI,EAAE,IAA8B;QACpC,IAAI,EAAE,IAA8B;KACrC,EACD,EAAE,EACF,GAAG,CACJ,CAAC;IACF,OAAO,IAAI,UAAU,CAAC,IAAI,CAAC,CAAC;AAC9B,CAAC;AAcD;;;;;;;GAOG;AACH,MAAM,UAAU,eAAe,CAC7B,SAA8B;IAE9B,MAAM,CAAC,GAAG,SAAS,CAAC,QAA0C,CAAC;IAC/D,OAAO;QACL,YAAY,EAAE,IAAI,UAAU,CAAC,SAAS,CAAC,KAAK,CAAC;QAC7C,iBAAiB,EAAE,IAAI,UAAU,CAAC,CAAC,CAAC,iBAAiB,CAAC;QACtD,cAAc,EAAE,IAAI,UAAU,CAAC,CAAC,CAAC,cAAc,CAAC;QAChD,SAAS,EAAE,IAAI,UAAU,CAAC,CAAC,CAAC,SAAS,CAAC;KACvC,CAAC;AACJ,CAAC"}
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@sudp-protocol/authorizer",
3
+ "version": "0.1.0",
4
+ "description": "Authorizer-side primitives for SUDP (Secret-Use Delegation Protocol): canonical JSON, β computation, wrapping-key derivation, AEAD-as-wrap, plus a WebAuthn adapter under the ./webauthn subpath.",
5
+ "license": "Apache-2.0",
6
+ "author": "Miracle <xhyumiracle@gmail.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/xhyumiracle/sudp",
10
+ "directory": "authorizer/ts"
11
+ },
12
+ "type": "module",
13
+ "main": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./webauthn": {
21
+ "types": "./dist/webauthn.d.ts",
22
+ "import": "./dist/webauthn.js"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "README.md"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest",
34
+ "typecheck": "tsc --noEmit",
35
+ "prepublishOnly": "npm run build"
36
+ },
37
+ "dependencies": {
38
+ "@noble/ciphers": "^2.2.0"
39
+ },
40
+ "devDependencies": {
41
+ "@types/node": "^24.0.0",
42
+ "typescript": "^6.0.0",
43
+ "vitest": "^2.1.0"
44
+ },
45
+ "keywords": [
46
+ "sudp",
47
+ "secret-use-delegation",
48
+ "authorizer",
49
+ "passkey",
50
+ "webauthn",
51
+ "agent-security"
52
+ ],
53
+ "engines": {
54
+ "node": ">=20"
55
+ }
56
+ }
package/src/aad.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { concatBytes, u16beBytes, utf8 } from "./bytes.js";
2
+
3
+ export const DS_WRAP = utf8("sudp/v1/wrap");
4
+ export const DS_SEAL = utf8("sudp/v1/seal");
5
+
6
+ /**
7
+ * Current wrap version. Bumped together on both ends of the protocol if the
8
+ * AAD shape ever changes.
9
+ */
10
+ export const WRAP_VERSION = 0x0001;
11
+
12
+ /**
13
+ * Canonical AAD for the AEAD-as-wrap profile:
14
+ *
15
+ * DS_WRAP ‖ credentialId ‖ ver_be(u16, big-endian)
16
+ *
17
+ * Bound as associated data when sealing/opening `K̂_c` under `W_c`, so a
18
+ * peer-wrapped record cannot be substituted across credentials or versions.
19
+ */
20
+ export function wrapBindingAd(
21
+ credentialId: Uint8Array,
22
+ wrapVersion: number = WRAP_VERSION,
23
+ ): Uint8Array {
24
+ return concatBytes(DS_WRAP, credentialId, u16beBytes(wrapVersion));
25
+ }
26
+
27
+ /**
28
+ * Canonical AAD for the sealed-body AEAD layer:
29
+ *
30
+ * DS_SEAL ‖ ver_be(u16, big-endian)
31
+ */
32
+ export function sealAd(wrapVersion: number = WRAP_VERSION): Uint8Array {
33
+ return concatBytes(DS_SEAL, u16beBytes(wrapVersion));
34
+ }
package/src/aead.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { xchacha20poly1305 } from "@noble/ciphers/chacha.js";
2
+
3
+ const XNONCE_LEN = 24;
4
+ const XTAG_LEN = 16;
5
+
6
+ /**
7
+ * XChaCha20-Poly1305 raw encrypt with a caller-supplied nonce.
8
+ *
9
+ * Output is `ciphertext ‖ tag` (no nonce prefix). Use {@link aeadSeal} for
10
+ * the standard SUDP wire format that prepends a freshly random nonce.
11
+ *
12
+ * MUST stay byte-for-byte aligned with the Rust crate's
13
+ * `sudp::primitives::Aead::encrypt`.
14
+ */
15
+ export function aeadEncrypt(
16
+ key: Uint8Array,
17
+ nonce: Uint8Array,
18
+ plaintext: Uint8Array,
19
+ aad: Uint8Array,
20
+ ): Uint8Array {
21
+ if (nonce.byteLength !== XNONCE_LEN) {
22
+ throw new Error(`aeadEncrypt: nonce must be ${XNONCE_LEN} bytes, got ${nonce.byteLength}`);
23
+ }
24
+ return xchacha20poly1305(key, nonce, aad).encrypt(plaintext);
25
+ }
26
+
27
+ /**
28
+ * XChaCha20-Poly1305 thin wrapper.
29
+ *
30
+ * Wire layout (MUST match `sudp::primitives::Aead::seal`):
31
+ *
32
+ * nonce(24 bytes) ‖ ciphertext ‖ tag(16 bytes)
33
+ *
34
+ * The 24-byte nonce is freshly generated per call. The caller supplies the
35
+ * canonical AAD (see {@link wrapBindingAd}, {@link sealAd}).
36
+ */
37
+ export function aeadSeal(
38
+ key: Uint8Array,
39
+ plaintext: Uint8Array,
40
+ aad: Uint8Array,
41
+ ): Uint8Array {
42
+ const nonce = crypto.getRandomValues(new Uint8Array(XNONCE_LEN));
43
+ const ct = aeadEncrypt(key, nonce, plaintext, aad);
44
+ const out = new Uint8Array(XNONCE_LEN + ct.byteLength);
45
+ out.set(nonce, 0);
46
+ out.set(ct, XNONCE_LEN);
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * Counterpart to {@link aeadSeal}. Throws if the AAD/nonce/ciphertext are
52
+ * not authentic under `key`.
53
+ */
54
+ export function aeadOpen(
55
+ key: Uint8Array,
56
+ sealed: Uint8Array,
57
+ aad: Uint8Array,
58
+ ): Uint8Array {
59
+ if (sealed.byteLength <= XNONCE_LEN + XTAG_LEN) {
60
+ throw new Error("aeadOpen: sealed blob is too short");
61
+ }
62
+ const nonce = sealed.slice(0, XNONCE_LEN);
63
+ const ct = sealed.slice(XNONCE_LEN);
64
+ return xchacha20poly1305(key, nonce, aad).decrypt(ct);
65
+ }
package/src/binding.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { concatBytes, utf8 } from "./bytes.js";
2
+ import { canonicalize } from "./canonical.js";
3
+ import { sha256 } from "./hash.js";
4
+
5
+ /**
6
+ * Domain separation tag for the Phase II.2 binding hash. Matches the Rust
7
+ * crate's `sudp::primitives::domain::DS_BIND` byte-for-byte.
8
+ *
9
+ * β = SHA-256(DS_BIND ‖ r ‖ SHA-256(canonical(o)))
10
+ */
11
+ export const DS_BIND = utf8("sudp/v1/bind");
12
+
13
+ /**
14
+ * Compute the binding hash `β` for a given operation, freshness `r`, and
15
+ * domain separation tag.
16
+ *
17
+ * β = SHA-256(domain ‖ r ‖ SHA-256(canonical(op)))
18
+ *
19
+ * The Authorizer signs `β` with its authenticator. The custodian recomputes
20
+ * `β` from the redeemed grant's `(o, r)` and verifies the signature.
21
+ *
22
+ * Pass {@link DS_BIND} for the default profile; other domains may be used
23
+ * by adjacent ceremonies (e.g. setup attestation) and live in the
24
+ * deployment.
25
+ */
26
+ export async function computeBinding(
27
+ domain: Uint8Array,
28
+ r: Uint8Array,
29
+ op: unknown,
30
+ ): Promise<Uint8Array> {
31
+ const opHash = await sha256(canonicalize(op));
32
+ return sha256(concatBytes(domain, r, opHash));
33
+ }
package/src/bytes.ts ADDED
@@ -0,0 +1,89 @@
1
+ const enc = new TextEncoder();
2
+
3
+ export function utf8(s: string): Uint8Array {
4
+ return enc.encode(s);
5
+ }
6
+
7
+ export function concatBytes(...parts: readonly Uint8Array[]): Uint8Array {
8
+ let total = 0;
9
+ for (const p of parts) total += p.byteLength;
10
+ const out = new Uint8Array(total);
11
+ let off = 0;
12
+ for (const p of parts) {
13
+ out.set(p, off);
14
+ off += p.byteLength;
15
+ }
16
+ return out;
17
+ }
18
+
19
+ export function u16beBytes(n: number): Uint8Array {
20
+ if (!Number.isInteger(n) || n < 0 || n > 0xffff) {
21
+ throw new Error(`u16beBytes: out of range: ${n}`);
22
+ }
23
+ return new Uint8Array([(n >> 8) & 0xff, n & 0xff]);
24
+ }
25
+
26
+ const B64URL_ALPHA = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
27
+ const B64URL_LOOKUP = (() => {
28
+ const t = new Int8Array(256).fill(-1);
29
+ for (let i = 0; i < B64URL_ALPHA.length; i++) t[B64URL_ALPHA.charCodeAt(i)] = i;
30
+ return t;
31
+ })();
32
+
33
+ function alpha(i: number): string {
34
+ // Index is always masked to 0..63, so B64URL_ALPHA[i] is defined.
35
+ return B64URL_ALPHA[i]!;
36
+ }
37
+
38
+ export function bytesToB64Url(b: Uint8Array): string {
39
+ let out = "";
40
+ let i = 0;
41
+ for (; i + 3 <= b.length; i += 3) {
42
+ const x = (b[i]! << 16) | (b[i + 1]! << 8) | b[i + 2]!;
43
+ out += alpha((x >> 18) & 0x3f) + alpha((x >> 12) & 0x3f) + alpha((x >> 6) & 0x3f) + alpha(x & 0x3f);
44
+ }
45
+ const rem = b.length - i;
46
+ if (rem === 1) {
47
+ const x = b[i]! << 16;
48
+ out += alpha((x >> 18) & 0x3f) + alpha((x >> 12) & 0x3f);
49
+ } else if (rem === 2) {
50
+ const x = (b[i]! << 16) | (b[i + 1]! << 8);
51
+ out += alpha((x >> 18) & 0x3f) + alpha((x >> 12) & 0x3f) + alpha((x >> 6) & 0x3f);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ export function b64UrlToBytes(s: string): Uint8Array {
57
+ const norm = s.replace(/=+$/, "");
58
+ const len = norm.length;
59
+ const fullGroups = Math.floor(len / 4);
60
+ const rem = len - fullGroups * 4;
61
+ if (rem === 1) throw new Error("b64UrlToBytes: invalid length");
62
+ const out = new Uint8Array(fullGroups * 3 + (rem === 0 ? 0 : rem - 1));
63
+ let outOff = 0;
64
+ let i = 0;
65
+ for (; i + 4 <= len; i += 4) {
66
+ const a = B64URL_LOOKUP[norm.charCodeAt(i)]!;
67
+ const b = B64URL_LOOKUP[norm.charCodeAt(i + 1)]!;
68
+ const c = B64URL_LOOKUP[norm.charCodeAt(i + 2)]!;
69
+ const d = B64URL_LOOKUP[norm.charCodeAt(i + 3)]!;
70
+ if ((a | b | c | d) < 0) throw new Error("b64UrlToBytes: invalid character");
71
+ out[outOff++] = (a << 2) | (b >> 4);
72
+ out[outOff++] = ((b & 0x0f) << 4) | (c >> 2);
73
+ out[outOff++] = ((c & 0x03) << 6) | d;
74
+ }
75
+ if (rem === 2) {
76
+ const a = B64URL_LOOKUP[norm.charCodeAt(i)]!;
77
+ const b = B64URL_LOOKUP[norm.charCodeAt(i + 1)]!;
78
+ if ((a | b) < 0) throw new Error("b64UrlToBytes: invalid character");
79
+ out[outOff++] = (a << 2) | (b >> 4);
80
+ } else if (rem === 3) {
81
+ const a = B64URL_LOOKUP[norm.charCodeAt(i)]!;
82
+ const b = B64URL_LOOKUP[norm.charCodeAt(i + 1)]!;
83
+ const c = B64URL_LOOKUP[norm.charCodeAt(i + 2)]!;
84
+ if ((a | b | c) < 0) throw new Error("b64UrlToBytes: invalid character");
85
+ out[outOff++] = (a << 2) | (b >> 4);
86
+ out[outOff++] = ((b & 0x0f) << 4) | (c >> 2);
87
+ }
88
+ return out;
89
+ }
@@ -0,0 +1,54 @@
1
+ import { utf8 } from "./bytes.js";
2
+
3
+ /**
4
+ * JCS-style canonical JSON encoder (RFC 8785 subset).
5
+ *
6
+ * Properties:
7
+ * - Object keys are sorted lexicographically by UTF-16 code unit.
8
+ * - Strings use `JSON.stringify` escaping.
9
+ * - Numbers must be finite. Floats are rejected — they have no
10
+ * byte-reproducible canonical form across endpoints and would be a
11
+ * substitution vector against `H(o)`. Use integers, strings, booleans,
12
+ * nulls, arrays, or nested objects.
13
+ * - `undefined` is treated as `null`.
14
+ *
15
+ * This MUST stay byte-for-byte aligned with the Rust crate's
16
+ * `sudp::canonical::canonicalize_strict`. The `protocol/test_vectors/`
17
+ * directory carries the conformance suite.
18
+ */
19
+ export function canonicalize(value: unknown): Uint8Array {
20
+ return utf8(canonicalizeStr(value));
21
+ }
22
+
23
+ function canonicalizeStr(v: unknown): string {
24
+ if (v === null || v === undefined) return "null";
25
+ if (typeof v === "boolean") return v ? "true" : "false";
26
+ if (typeof v === "number") {
27
+ if (!Number.isFinite(v)) {
28
+ throw new Error("canonicalize: non-finite number is not allowed");
29
+ }
30
+ if (!Number.isInteger(v)) {
31
+ throw new Error(
32
+ "canonicalize: float values are rejected (no byte-reproducible canonical form)",
33
+ );
34
+ }
35
+ return v.toString();
36
+ }
37
+ if (typeof v === "bigint") return v.toString();
38
+ if (typeof v === "string") return JSON.stringify(v);
39
+ if (Array.isArray(v)) {
40
+ return "[" + v.map(canonicalizeStr).join(",") + "]";
41
+ }
42
+ if (typeof v === "object") {
43
+ const obj = v as Record<string, unknown>;
44
+ const keys = Object.keys(obj).sort();
45
+ return (
46
+ "{" +
47
+ keys
48
+ .map((k) => JSON.stringify(k) + ":" + canonicalizeStr(obj[k]))
49
+ .join(",") +
50
+ "}"
51
+ );
52
+ }
53
+ throw new Error(`canonicalize: unsupported value type: ${typeof v}`);
54
+ }
package/src/hash.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * SHA-256 over a byte buffer. Thin wrapper over the platform's WebCrypto.
3
+ */
4
+ export async function sha256(data: Uint8Array): Promise<Uint8Array> {
5
+ // `crypto.subtle` is available in modern browsers and Node >= 20.
6
+ const buf = await crypto.subtle.digest("SHA-256", data as unknown as ArrayBuffer);
7
+ return new Uint8Array(buf);
8
+ }
package/src/index.ts ADDED
@@ -0,0 +1,27 @@
1
+ /**
2
+ * `@sudp-protocol/authorizer` — Authorizer-side primitives for the
3
+ * Secret-Use Delegation Protocol.
4
+ *
5
+ * This entry point is **carrier-agnostic**: it carries only the protocol
6
+ * cryptography (canonical JSON, β computation, wrapping-key derivation,
7
+ * AEAD-as-wrap) and intentionally does **not** know about WebAuthn,
8
+ * passkeys, HTTP, or any specific authenticator.
9
+ *
10
+ * For the WebAuthn PRF → y_c adapter and assertion helpers, import from
11
+ * `@sudp-protocol/authorizer/webauthn`.
12
+ */
13
+
14
+ export {
15
+ utf8,
16
+ concatBytes,
17
+ u16beBytes,
18
+ bytesToB64Url,
19
+ b64UrlToBytes,
20
+ } from "./bytes.js";
21
+
22
+ export { canonicalize } from "./canonical.js";
23
+ export { sha256 } from "./hash.js";
24
+ export { computeBinding, DS_BIND } from "./binding.js";
25
+ export { deriveWrappingKey } from "./kdf.js";
26
+ export { wrapBindingAd, sealAd, DS_WRAP, DS_SEAL, WRAP_VERSION } from "./aad.js";
27
+ export { aeadEncrypt, aeadSeal, aeadOpen } from "./aead.js";
package/src/kdf.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { concatBytes, u16beBytes } from "./bytes.js";
2
+ import { DS_WRAP, WRAP_VERSION } from "./aad.js";
3
+
4
+ /**
5
+ * Derive the per-credential wrapping key `W_c` from the Authorizer's
6
+ * user-key `y_c` (a 32-byte secret produced by the authenticator).
7
+ *
8
+ * W_c = HKDF-SHA-256(y_c, salt = prf_salt, info = DS_WRAP ‖ credId ‖ ver_be)
9
+ *
10
+ * `y_c` must arrive at the Authorizer side already shaped to 32 bytes; how
11
+ * it is produced is authenticator-specific and outside the SUDP core (see
12
+ * `./webauthn` for the WebAuthn PRF → y_c adapter, but custom authenticators
13
+ * may provide y_c directly).
14
+ *
15
+ * MUST stay byte-for-byte aligned with the Rust crate's
16
+ * `sudp::crypto::kdf::derive_wrapping_key`.
17
+ */
18
+ export async function deriveWrappingKey(
19
+ userKey: Uint8Array,
20
+ prfSalt: Uint8Array,
21
+ credentialId: Uint8Array,
22
+ wrapVersion: number = WRAP_VERSION,
23
+ ): Promise<Uint8Array> {
24
+ const km = await crypto.subtle.importKey(
25
+ "raw",
26
+ userKey as unknown as ArrayBuffer,
27
+ "HKDF",
28
+ false,
29
+ ["deriveBits"],
30
+ );
31
+ const info = concatBytes(DS_WRAP, credentialId, u16beBytes(wrapVersion));
32
+ const bits = await crypto.subtle.deriveBits(
33
+ {
34
+ name: "HKDF",
35
+ hash: "SHA-256",
36
+ salt: prfSalt as unknown as ArrayBuffer,
37
+ info: info as unknown as ArrayBuffer,
38
+ },
39
+ km,
40
+ 256,
41
+ );
42
+ return new Uint8Array(bits);
43
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `@sudp-protocol/authorizer/webauthn` — WebAuthn adapter for the Authorizer side.
3
+ *
4
+ * The core `@sudp-protocol/authorizer` package is intentionally **signer-agnostic**:
5
+ * it accepts a 32-byte `userKey` (= `y_c`) and does not care how the
6
+ * Authorizer obtained it. This subpath is the WebAuthn-specific glue:
7
+ *
8
+ * - it runs HKDF over the raw PRF extension output to produce `y_c`;
9
+ * - it shapes a `PublicKeyCredential` assertion into the wire form the
10
+ * custodian expects.
11
+ *
12
+ * Other realisations (Yubikey static, secure-enclave, HSM, mock for tests)
13
+ * live in their own adapters and never touch this file.
14
+ */
15
+
16
+ import { utf8 } from "./bytes.js";
17
+
18
+ /**
19
+ * Default HKDF `info` for WebAuthn PRF → userKey derivation. Identifies
20
+ * this specific adapter on the wire.
21
+ */
22
+ export const DEFAULT_PRF_INFO = utf8("sudp/v1/webauthn-prf-userkey");
23
+
24
+ /**
25
+ * Options for {@link prfToUserKey}.
26
+ */
27
+ export interface PrfToUserKeyOptions {
28
+ /**
29
+ * HKDF `info` parameter. Defaults to {@link DEFAULT_PRF_INFO}; deployments
30
+ * that need to differentiate multiple WebAuthn-PRF surfaces under the same
31
+ * custodian may override it.
32
+ */
33
+ readonly info?: Uint8Array;
34
+ /**
35
+ * HKDF `salt` parameter. Defaults to a 32-byte zero salt (extract-only
36
+ * HKDF when the IKM is already uniform, per RFC 5869 §3.1).
37
+ */
38
+ readonly salt?: Uint8Array;
39
+ }
40
+
41
+ const DEFAULT_PRF_SALT = new Uint8Array(32);
42
+
43
+ /**
44
+ * Derive a 32-byte `userKey` (= `y_c`) from the WebAuthn PRF extension's
45
+ * raw first-output bytes.
46
+ *
47
+ * y_c = HKDF-SHA-256(prfOutput, salt, info)
48
+ *
49
+ * The `info` string is what makes this adapter distinct from other ways of
50
+ * producing `y_c` — picking the same `info` in a different authenticator
51
+ * would lock you to compatible wire bytes.
52
+ */
53
+ export async function prfToUserKey(
54
+ prfOutput: Uint8Array,
55
+ options?: PrfToUserKeyOptions,
56
+ ): Promise<Uint8Array> {
57
+ const info = options?.info ?? DEFAULT_PRF_INFO;
58
+ const salt = options?.salt ?? DEFAULT_PRF_SALT;
59
+ const km = await crypto.subtle.importKey(
60
+ "raw",
61
+ prfOutput as unknown as ArrayBuffer,
62
+ "HKDF",
63
+ false,
64
+ ["deriveBits"],
65
+ );
66
+ const bits = await crypto.subtle.deriveBits(
67
+ {
68
+ name: "HKDF",
69
+ hash: "SHA-256",
70
+ salt: salt as unknown as ArrayBuffer,
71
+ info: info as unknown as ArrayBuffer,
72
+ },
73
+ km,
74
+ 256,
75
+ );
76
+ return new Uint8Array(bits);
77
+ }
78
+
79
+ /**
80
+ * Wire-shape of a WebAuthn assertion that the custodian's WebAuthn
81
+ * `Authenticator` realisation can verify. Field names match the daemon's
82
+ * expected wire format.
83
+ */
84
+ export interface AssertionWire {
85
+ readonly credentialId: Uint8Array;
86
+ readonly authenticatorData: Uint8Array;
87
+ readonly clientDataJSON: Uint8Array;
88
+ readonly signature: Uint8Array;
89
+ }
90
+
91
+ /**
92
+ * Extract the four fields a custodian needs from a `PublicKeyCredential`
93
+ * assertion.
94
+ *
95
+ * Note: this returns raw bytes. Callers that need base64url wire
96
+ * encoding can run the fields through `bytesToB64Url` from the core
97
+ * package.
98
+ */
99
+ export function assertionToWire(
100
+ assertion: PublicKeyCredential,
101
+ ): AssertionWire {
102
+ const r = assertion.response as AuthenticatorAssertionResponse;
103
+ return {
104
+ credentialId: new Uint8Array(assertion.rawId),
105
+ authenticatorData: new Uint8Array(r.authenticatorData),
106
+ clientDataJSON: new Uint8Array(r.clientDataJSON),
107
+ signature: new Uint8Array(r.signature),
108
+ };
109
+ }