@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.
- package/README.md +93 -0
- package/dist/aad.d.ts +23 -0
- package/dist/aad.d.ts.map +1 -0
- package/dist/aad.js +28 -0
- package/dist/aad.js.map +1 -0
- package/dist/aead.d.ts +27 -0
- package/dist/aead.d.ts.map +1 -0
- package/dist/aead.js +49 -0
- package/dist/aead.js.map +1 -0
- package/dist/binding.d.ts +22 -0
- package/dist/binding.d.ts.map +1 -0
- package/dist/binding.js +28 -0
- package/dist/binding.js.map +1 -0
- package/dist/bytes.d.ts +6 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +91 -0
- package/dist/bytes.js.map +1 -0
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +53 -0
- package/dist/canonical.js.map +1 -0
- package/dist/hash.d.ts +5 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +9 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/kdf.d.ts +16 -0
- package/dist/kdf.d.ts.map +1 -0
- package/dist/kdf.js +28 -0
- package/dist/kdf.js.map +1 -0
- package/dist/webauthn.d.ts +67 -0
- package/dist/webauthn.d.ts.map +1 -0
- package/dist/webauthn.js +61 -0
- package/dist/webauthn.js.map +1 -0
- package/package.json +56 -0
- package/src/aad.ts +34 -0
- package/src/aead.ts +65 -0
- package/src/binding.ts +33 -0
- package/src/bytes.ts +89 -0
- package/src/canonical.ts +54 -0
- package/src/hash.ts +8 -0
- package/src/index.ts +27 -0
- package/src/kdf.ts +43 -0
- package/src/webauthn.ts +109 -0
|
@@ -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"}
|
package/dist/webauthn.js
ADDED
|
@@ -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
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -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
|
+
}
|
package/src/webauthn.ts
ADDED
|
@@ -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
|
+
}
|