@bolyra/receipts 0.6.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/dist/canonical.d.ts +1 -0
- package/dist/canonical.js +14 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +11 -0
- package/dist/receipt.d.ts +5 -0
- package/dist/receipt.js +46 -0
- package/dist/sign.d.ts +4 -0
- package/dist/sign.js +144 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.js +2 -0
- package/package.json +45 -0
- package/src/canonical.ts +11 -0
- package/src/index.ts +9 -0
- package/src/receipt.ts +52 -0
- package/src/sign.ts +130 -0
- package/src/types.ts +86 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function canonicalize(obj: unknown): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canonicalize = canonicalize;
|
|
4
|
+
function canonicalize(obj) {
|
|
5
|
+
return JSON.stringify(obj, (_key, value) => {
|
|
6
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
7
|
+
return Object.keys(value).sort().reduce((sorted, k) => {
|
|
8
|
+
sorted[k] = value[k];
|
|
9
|
+
return sorted;
|
|
10
|
+
}, {});
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
});
|
|
14
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.hashPayload = exports.verifyReceipt = exports.signReceipt = exports.createAuthReceipt = exports.canonicalize = void 0;
|
|
4
|
+
var canonical_1 = require("./canonical");
|
|
5
|
+
Object.defineProperty(exports, "canonicalize", { enumerable: true, get: function () { return canonical_1.canonicalize; } });
|
|
6
|
+
var receipt_1 = require("./receipt");
|
|
7
|
+
Object.defineProperty(exports, "createAuthReceipt", { enumerable: true, get: function () { return receipt_1.createAuthReceipt; } });
|
|
8
|
+
var sign_1 = require("./sign");
|
|
9
|
+
Object.defineProperty(exports, "signReceipt", { enumerable: true, get: function () { return sign_1.signReceipt; } });
|
|
10
|
+
Object.defineProperty(exports, "verifyReceipt", { enumerable: true, get: function () { return sign_1.verifyReceipt; } });
|
|
11
|
+
Object.defineProperty(exports, "hashPayload", { enumerable: true, get: function () { return sign_1.hashPayload; } });
|
package/dist/receipt.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createAuthReceipt = createAuthReceipt;
|
|
4
|
+
const sha256_1 = require("@noble/hashes/sha256");
|
|
5
|
+
const utils_1 = require("@noble/hashes/utils");
|
|
6
|
+
const canonical_1 = require("./canonical");
|
|
7
|
+
function sha256Hex(data) {
|
|
8
|
+
const encoder = new TextEncoder();
|
|
9
|
+
return (0, utils_1.bytesToHex)((0, sha256_1.sha256)(encoder.encode(data)));
|
|
10
|
+
}
|
|
11
|
+
function createAuthReceipt(input, config) {
|
|
12
|
+
const humanProofHash = sha256Hex((0, canonical_1.canonicalize)(input.humanProof.proof));
|
|
13
|
+
const agentProofHash = sha256Hex((0, canonical_1.canonicalize)(input.agentProof.proof));
|
|
14
|
+
const publicSignalsHash = sha256Hex((0, canonical_1.canonicalize)([...input.humanPublicSignals, ...input.agentPublicSignals]));
|
|
15
|
+
const delegationChainHash = input.delegationChain
|
|
16
|
+
? sha256Hex((0, canonical_1.canonicalize)(input.delegationChain))
|
|
17
|
+
: undefined;
|
|
18
|
+
return {
|
|
19
|
+
v: 1,
|
|
20
|
+
kind: 'bolyra.auth',
|
|
21
|
+
issuedAt: Math.floor(Date.now() / 1000),
|
|
22
|
+
issuer: config.issuer,
|
|
23
|
+
keyId: config.keyId,
|
|
24
|
+
subject: {
|
|
25
|
+
rootDid: input.rootDid,
|
|
26
|
+
actingDid: input.actingDid,
|
|
27
|
+
credentialCommitment: input.credentialCommitment,
|
|
28
|
+
effectiveCommitment: input.effectiveCommitment,
|
|
29
|
+
},
|
|
30
|
+
decision: {
|
|
31
|
+
allowed: input.allowed,
|
|
32
|
+
...(input.reasonCode !== undefined && { reasonCode: input.reasonCode }),
|
|
33
|
+
score: input.score,
|
|
34
|
+
permissionBitmask: input.permissionBitmask,
|
|
35
|
+
chainDepth: input.chainDepth,
|
|
36
|
+
},
|
|
37
|
+
proof: {
|
|
38
|
+
bundleVersion: input.bundleVersion,
|
|
39
|
+
nonce: input.nonce,
|
|
40
|
+
humanProofHash,
|
|
41
|
+
agentProofHash,
|
|
42
|
+
publicSignalsHash,
|
|
43
|
+
...(delegationChainHash !== undefined && { delegationChainHash }),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
package/dist/sign.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ReceiptPayload, ReceiptSignerConfig, SignedReceipt } from './types';
|
|
2
|
+
export declare function hashPayload(payload: ReceiptPayload): string;
|
|
3
|
+
export declare function signReceipt(payload: ReceiptPayload, config: ReceiptSignerConfig): SignedReceipt;
|
|
4
|
+
export declare function verifyReceipt(receipt: SignedReceipt, expectedSigner?: string): boolean;
|
package/dist/sign.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.hashPayload = hashPayload;
|
|
37
|
+
exports.signReceipt = signReceipt;
|
|
38
|
+
exports.verifyReceipt = verifyReceipt;
|
|
39
|
+
const secp256k1 = __importStar(require("@noble/secp256k1"));
|
|
40
|
+
const hmac_1 = require("@noble/hashes/hmac");
|
|
41
|
+
const sha256_1 = require("@noble/hashes/sha256");
|
|
42
|
+
const sha3_1 = require("@noble/hashes/sha3");
|
|
43
|
+
const utils_1 = require("@noble/hashes/utils");
|
|
44
|
+
const canonical_1 = require("./canonical");
|
|
45
|
+
// @noble/secp256k1 v2 requires hmacSha256Sync to be set for synchronous signing.
|
|
46
|
+
secp256k1.etc.hmacSha256Sync = (k, ...m) => {
|
|
47
|
+
const h = hmac_1.hmac.create(sha256_1.sha256, k);
|
|
48
|
+
for (const msg of m)
|
|
49
|
+
h.update(msg);
|
|
50
|
+
return h.digest();
|
|
51
|
+
};
|
|
52
|
+
function hexToBytes(hex) {
|
|
53
|
+
const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
54
|
+
const bytes = new Uint8Array(clean.length / 2);
|
|
55
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
56
|
+
bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
57
|
+
}
|
|
58
|
+
return bytes;
|
|
59
|
+
}
|
|
60
|
+
function utf8ToBytes(str) {
|
|
61
|
+
return new TextEncoder().encode(str);
|
|
62
|
+
}
|
|
63
|
+
function deriveAddress(publicKeyBytes) {
|
|
64
|
+
// Uncompressed public key is 65 bytes (04 || x || y).
|
|
65
|
+
// For Ethereum address we hash the 64-byte x||y (skip the 04 prefix).
|
|
66
|
+
const uncompressed = publicKeyBytes.length === 65
|
|
67
|
+
? publicKeyBytes.slice(1)
|
|
68
|
+
: publicKeyBytes;
|
|
69
|
+
const hash = (0, sha3_1.keccak_256)(uncompressed);
|
|
70
|
+
return '0x' + (0, utils_1.bytesToHex)(hash.slice(hash.length - 20));
|
|
71
|
+
}
|
|
72
|
+
function hashPayload(payload) {
|
|
73
|
+
const canonical = (0, canonical_1.canonicalize)(payload);
|
|
74
|
+
const hash = (0, sha3_1.keccak_256)(utf8ToBytes(canonical));
|
|
75
|
+
return '0x' + (0, utils_1.bytesToHex)(hash);
|
|
76
|
+
}
|
|
77
|
+
function signReceipt(payload, config) {
|
|
78
|
+
const canonical = (0, canonical_1.canonicalize)(payload);
|
|
79
|
+
const hash = (0, sha3_1.keccak_256)(utf8ToBytes(canonical));
|
|
80
|
+
const privateKeyBytes = hexToBytes(config.privateKey);
|
|
81
|
+
const sig = secp256k1.sign(hash, privateKeyBytes);
|
|
82
|
+
const r = sig.r.toString(16).padStart(64, '0');
|
|
83
|
+
const s = sig.s.toString(16).padStart(64, '0');
|
|
84
|
+
const v = (sig.recovery + 27).toString(16).padStart(2, '0');
|
|
85
|
+
const signatureHex = '0x' + r + s + v;
|
|
86
|
+
const publicKey = secp256k1.getPublicKey(privateKeyBytes, false);
|
|
87
|
+
const signer = deriveAddress(publicKey);
|
|
88
|
+
const payloadHash = '0x' + (0, utils_1.bytesToHex)(hash);
|
|
89
|
+
const id = '0x' + payloadHash.slice(2, 18);
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
payload,
|
|
93
|
+
signature: {
|
|
94
|
+
alg: 'ES256K',
|
|
95
|
+
keyId: config.keyId,
|
|
96
|
+
signer,
|
|
97
|
+
payloadHash,
|
|
98
|
+
value: signatureHex,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function verifyReceipt(receipt, expectedSigner) {
|
|
103
|
+
try {
|
|
104
|
+
// Recompute hash from payload
|
|
105
|
+
const canonical = (0, canonical_1.canonicalize)(receipt.payload);
|
|
106
|
+
const hash = (0, sha3_1.keccak_256)(utf8ToBytes(canonical));
|
|
107
|
+
const recomputedHash = '0x' + (0, utils_1.bytesToHex)(hash);
|
|
108
|
+
// Check hash matches
|
|
109
|
+
if (recomputedHash !== receipt.signature.payloadHash) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// Extract r, s, v from 65-byte signature
|
|
113
|
+
const sigHex = receipt.signature.value.startsWith('0x')
|
|
114
|
+
? receipt.signature.value.slice(2)
|
|
115
|
+
: receipt.signature.value;
|
|
116
|
+
const rHex = sigHex.slice(0, 64);
|
|
117
|
+
const sHex = sigHex.slice(64, 128);
|
|
118
|
+
const vByte = parseInt(sigHex.slice(128, 130), 16);
|
|
119
|
+
const recoveryBit = vByte - 27;
|
|
120
|
+
// Recover public key
|
|
121
|
+
const rs = rHex + sHex;
|
|
122
|
+
const recovered = secp256k1.Signature
|
|
123
|
+
.fromCompact(rs)
|
|
124
|
+
.addRecoveryBit(recoveryBit)
|
|
125
|
+
.recoverPublicKey(hash);
|
|
126
|
+
const recoveredAddress = deriveAddress(recovered.toRawBytes(false));
|
|
127
|
+
// Always check recovered address matches the claimed signer in the receipt.
|
|
128
|
+
// Without this, an attacker can change receipt.signature.signer to any
|
|
129
|
+
// address and verifyReceipt still returns true.
|
|
130
|
+
const claimedSigner = receipt.signature.signer.toLowerCase();
|
|
131
|
+
const actual = recoveredAddress.toLowerCase();
|
|
132
|
+
if (claimedSigner !== actual) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
// Optional: also check against an externally expected signer
|
|
136
|
+
if (expectedSigner && expectedSigner.toLowerCase() !== actual) {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export interface ReceiptPayload {
|
|
2
|
+
v: 1;
|
|
3
|
+
kind: 'bolyra.auth';
|
|
4
|
+
/** Unix seconds when the decision was made. */
|
|
5
|
+
issuedAt: number;
|
|
6
|
+
/** Server identifier. */
|
|
7
|
+
issuer: string;
|
|
8
|
+
/** Signing key identifier for rotation. */
|
|
9
|
+
keyId: string;
|
|
10
|
+
subject: {
|
|
11
|
+
/** Root credential DID. */
|
|
12
|
+
rootDid: string;
|
|
13
|
+
/** Acting agent DID (leaf of delegation chain, or root if no chain). */
|
|
14
|
+
actingDid: string;
|
|
15
|
+
credentialCommitment: string;
|
|
16
|
+
effectiveCommitment: string;
|
|
17
|
+
};
|
|
18
|
+
decision: {
|
|
19
|
+
allowed: boolean;
|
|
20
|
+
reasonCode?: string;
|
|
21
|
+
score: number;
|
|
22
|
+
/** Decimal string to avoid BigInt serialization issues. */
|
|
23
|
+
permissionBitmask: string;
|
|
24
|
+
chainDepth: number;
|
|
25
|
+
};
|
|
26
|
+
proof: {
|
|
27
|
+
bundleVersion: 1 | 2;
|
|
28
|
+
/** Decimal string. */
|
|
29
|
+
nonce: string;
|
|
30
|
+
/** SHA-256 of canonical JSON of humanProof.proof. */
|
|
31
|
+
humanProofHash: string;
|
|
32
|
+
/** SHA-256 of canonical JSON of agentProof.proof. */
|
|
33
|
+
agentProofHash: string;
|
|
34
|
+
/** SHA-256 of canonical JSON of all public signals (human + agent). */
|
|
35
|
+
publicSignalsHash: string;
|
|
36
|
+
/** SHA-256 of canonical JSON of delegationChain (if v=2). */
|
|
37
|
+
delegationChainHash?: string;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export interface SignedReceipt {
|
|
41
|
+
/** First 16 hex chars of payloadHash. */
|
|
42
|
+
id: string;
|
|
43
|
+
payload: ReceiptPayload;
|
|
44
|
+
signature: {
|
|
45
|
+
alg: 'ES256K';
|
|
46
|
+
keyId: string;
|
|
47
|
+
/** Ethereum address of the signer (hex, 0x-prefixed). */
|
|
48
|
+
signer: string;
|
|
49
|
+
/** keccak256 of canonical JSON payload (hex, 0x-prefixed). */
|
|
50
|
+
payloadHash: string;
|
|
51
|
+
/** r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes (hex). */
|
|
52
|
+
value: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
export interface ReceiptSignerConfig {
|
|
56
|
+
issuer: string;
|
|
57
|
+
keyId: string;
|
|
58
|
+
/** secp256k1 private key, 32 bytes, hex-encoded, 0x-prefixed. */
|
|
59
|
+
privateKey: string;
|
|
60
|
+
}
|
|
61
|
+
export interface AuthReceiptInput {
|
|
62
|
+
/** From BolyraAuthContext or equivalent. */
|
|
63
|
+
rootDid: string;
|
|
64
|
+
actingDid: string;
|
|
65
|
+
credentialCommitment: string;
|
|
66
|
+
effectiveCommitment: string;
|
|
67
|
+
allowed: boolean;
|
|
68
|
+
reasonCode?: string;
|
|
69
|
+
score: number;
|
|
70
|
+
permissionBitmask: string;
|
|
71
|
+
chainDepth: number;
|
|
72
|
+
/** Raw proof bundle for hashing. */
|
|
73
|
+
humanProof: {
|
|
74
|
+
proof: unknown;
|
|
75
|
+
};
|
|
76
|
+
agentProof: {
|
|
77
|
+
proof: unknown;
|
|
78
|
+
};
|
|
79
|
+
humanPublicSignals: string[];
|
|
80
|
+
agentPublicSignals: string[];
|
|
81
|
+
bundleVersion: 1 | 2;
|
|
82
|
+
nonce: string;
|
|
83
|
+
delegationChain?: unknown[];
|
|
84
|
+
}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bolyra/receipts",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Signed auth receipts for Bolyra ZKP verification decisions — canonical JSON, secp256k1 sign/verify, EVM-compatible r||s||v signatures.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"test": "jest --passWithNoTests",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@noble/secp256k1": "^2.2.0",
|
|
14
|
+
"@noble/hashes": "^1.7.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"typescript": "^5.5.0",
|
|
18
|
+
"@types/node": "^25.9.1",
|
|
19
|
+
"jest": "^30.4.2",
|
|
20
|
+
"ts-jest": "^29.2.0",
|
|
21
|
+
"@types/jest": "^30.0.0"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"dist/",
|
|
25
|
+
"src/",
|
|
26
|
+
"LICENSE",
|
|
27
|
+
"NOTICE"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/bolyra/bolyra"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"bolyra",
|
|
38
|
+
"receipts",
|
|
39
|
+
"zkp",
|
|
40
|
+
"secp256k1",
|
|
41
|
+
"signed-receipts",
|
|
42
|
+
"auth"
|
|
43
|
+
],
|
|
44
|
+
"license": "Apache-2.0"
|
|
45
|
+
}
|
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function canonicalize(obj: unknown): string {
|
|
2
|
+
return JSON.stringify(obj, (_key, value) => {
|
|
3
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
4
|
+
return Object.keys(value).sort().reduce((sorted, k) => {
|
|
5
|
+
sorted[k] = (value as Record<string, unknown>)[k];
|
|
6
|
+
return sorted;
|
|
7
|
+
}, {} as Record<string, unknown>);
|
|
8
|
+
}
|
|
9
|
+
return value;
|
|
10
|
+
});
|
|
11
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { canonicalize } from './canonical';
|
|
2
|
+
export { createAuthReceipt } from './receipt';
|
|
3
|
+
export { signReceipt, verifyReceipt, hashPayload } from './sign';
|
|
4
|
+
export type {
|
|
5
|
+
ReceiptPayload,
|
|
6
|
+
SignedReceipt,
|
|
7
|
+
ReceiptSignerConfig,
|
|
8
|
+
AuthReceiptInput,
|
|
9
|
+
} from './types';
|
package/src/receipt.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
2
|
+
import { bytesToHex } from '@noble/hashes/utils';
|
|
3
|
+
import { canonicalize } from './canonical';
|
|
4
|
+
import type { AuthReceiptInput, ReceiptPayload } from './types';
|
|
5
|
+
|
|
6
|
+
function sha256Hex(data: string): string {
|
|
7
|
+
const encoder = new TextEncoder();
|
|
8
|
+
return bytesToHex(sha256(encoder.encode(data)));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function createAuthReceipt(
|
|
12
|
+
input: AuthReceiptInput,
|
|
13
|
+
config: { issuer: string; keyId: string },
|
|
14
|
+
): ReceiptPayload {
|
|
15
|
+
const humanProofHash = sha256Hex(canonicalize(input.humanProof.proof));
|
|
16
|
+
const agentProofHash = sha256Hex(canonicalize(input.agentProof.proof));
|
|
17
|
+
const publicSignalsHash = sha256Hex(
|
|
18
|
+
canonicalize([...input.humanPublicSignals, ...input.agentPublicSignals]),
|
|
19
|
+
);
|
|
20
|
+
const delegationChainHash = input.delegationChain
|
|
21
|
+
? sha256Hex(canonicalize(input.delegationChain))
|
|
22
|
+
: undefined;
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
v: 1,
|
|
26
|
+
kind: 'bolyra.auth',
|
|
27
|
+
issuedAt: Math.floor(Date.now() / 1000),
|
|
28
|
+
issuer: config.issuer,
|
|
29
|
+
keyId: config.keyId,
|
|
30
|
+
subject: {
|
|
31
|
+
rootDid: input.rootDid,
|
|
32
|
+
actingDid: input.actingDid,
|
|
33
|
+
credentialCommitment: input.credentialCommitment,
|
|
34
|
+
effectiveCommitment: input.effectiveCommitment,
|
|
35
|
+
},
|
|
36
|
+
decision: {
|
|
37
|
+
allowed: input.allowed,
|
|
38
|
+
...(input.reasonCode !== undefined && { reasonCode: input.reasonCode }),
|
|
39
|
+
score: input.score,
|
|
40
|
+
permissionBitmask: input.permissionBitmask,
|
|
41
|
+
chainDepth: input.chainDepth,
|
|
42
|
+
},
|
|
43
|
+
proof: {
|
|
44
|
+
bundleVersion: input.bundleVersion,
|
|
45
|
+
nonce: input.nonce,
|
|
46
|
+
humanProofHash,
|
|
47
|
+
agentProofHash,
|
|
48
|
+
publicSignalsHash,
|
|
49
|
+
...(delegationChainHash !== undefined && { delegationChainHash }),
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/src/sign.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import * as secp256k1 from '@noble/secp256k1';
|
|
2
|
+
import { hmac } from '@noble/hashes/hmac';
|
|
3
|
+
import { sha256 } from '@noble/hashes/sha256';
|
|
4
|
+
import { keccak_256 } from '@noble/hashes/sha3';
|
|
5
|
+
import { bytesToHex } from '@noble/hashes/utils';
|
|
6
|
+
import { canonicalize } from './canonical';
|
|
7
|
+
import type { ReceiptPayload, ReceiptSignerConfig, SignedReceipt } from './types';
|
|
8
|
+
|
|
9
|
+
// @noble/secp256k1 v2 requires hmacSha256Sync to be set for synchronous signing.
|
|
10
|
+
secp256k1.etc.hmacSha256Sync = (k: Uint8Array, ...m: Uint8Array[]) => {
|
|
11
|
+
const h = hmac.create(sha256, k);
|
|
12
|
+
for (const msg of m) h.update(msg);
|
|
13
|
+
return h.digest();
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
17
|
+
const clean = hex.startsWith('0x') ? hex.slice(2) : hex;
|
|
18
|
+
const bytes = new Uint8Array(clean.length / 2);
|
|
19
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
20
|
+
bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
21
|
+
}
|
|
22
|
+
return bytes;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function utf8ToBytes(str: string): Uint8Array {
|
|
26
|
+
return new TextEncoder().encode(str);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function deriveAddress(publicKeyBytes: Uint8Array): string {
|
|
30
|
+
// Uncompressed public key is 65 bytes (04 || x || y).
|
|
31
|
+
// For Ethereum address we hash the 64-byte x||y (skip the 04 prefix).
|
|
32
|
+
const uncompressed = publicKeyBytes.length === 65
|
|
33
|
+
? publicKeyBytes.slice(1)
|
|
34
|
+
: publicKeyBytes;
|
|
35
|
+
const hash = keccak_256(uncompressed);
|
|
36
|
+
return '0x' + bytesToHex(hash.slice(hash.length - 20));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function hashPayload(payload: ReceiptPayload): string {
|
|
40
|
+
const canonical = canonicalize(payload);
|
|
41
|
+
const hash = keccak_256(utf8ToBytes(canonical));
|
|
42
|
+
return '0x' + bytesToHex(hash);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function signReceipt(
|
|
46
|
+
payload: ReceiptPayload,
|
|
47
|
+
config: ReceiptSignerConfig,
|
|
48
|
+
): SignedReceipt {
|
|
49
|
+
const canonical = canonicalize(payload);
|
|
50
|
+
const hash = keccak_256(utf8ToBytes(canonical));
|
|
51
|
+
const privateKeyBytes = hexToBytes(config.privateKey);
|
|
52
|
+
|
|
53
|
+
const sig = secp256k1.sign(hash, privateKeyBytes);
|
|
54
|
+
const r = sig.r.toString(16).padStart(64, '0');
|
|
55
|
+
const s = sig.s.toString(16).padStart(64, '0');
|
|
56
|
+
const v = (sig.recovery + 27).toString(16).padStart(2, '0');
|
|
57
|
+
const signatureHex = '0x' + r + s + v;
|
|
58
|
+
|
|
59
|
+
const publicKey = secp256k1.getPublicKey(privateKeyBytes, false);
|
|
60
|
+
const signer = deriveAddress(publicKey);
|
|
61
|
+
|
|
62
|
+
const payloadHash = '0x' + bytesToHex(hash);
|
|
63
|
+
const id = '0x' + payloadHash.slice(2, 18);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
id,
|
|
67
|
+
payload,
|
|
68
|
+
signature: {
|
|
69
|
+
alg: 'ES256K',
|
|
70
|
+
keyId: config.keyId,
|
|
71
|
+
signer,
|
|
72
|
+
payloadHash,
|
|
73
|
+
value: signatureHex,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function verifyReceipt(
|
|
79
|
+
receipt: SignedReceipt,
|
|
80
|
+
expectedSigner?: string,
|
|
81
|
+
): boolean {
|
|
82
|
+
try {
|
|
83
|
+
// Recompute hash from payload
|
|
84
|
+
const canonical = canonicalize(receipt.payload);
|
|
85
|
+
const hash = keccak_256(utf8ToBytes(canonical));
|
|
86
|
+
const recomputedHash = '0x' + bytesToHex(hash);
|
|
87
|
+
|
|
88
|
+
// Check hash matches
|
|
89
|
+
if (recomputedHash !== receipt.signature.payloadHash) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Extract r, s, v from 65-byte signature
|
|
94
|
+
const sigHex = receipt.signature.value.startsWith('0x')
|
|
95
|
+
? receipt.signature.value.slice(2)
|
|
96
|
+
: receipt.signature.value;
|
|
97
|
+
|
|
98
|
+
const rHex = sigHex.slice(0, 64);
|
|
99
|
+
const sHex = sigHex.slice(64, 128);
|
|
100
|
+
const vByte = parseInt(sigHex.slice(128, 130), 16);
|
|
101
|
+
const recoveryBit = vByte - 27;
|
|
102
|
+
|
|
103
|
+
// Recover public key
|
|
104
|
+
const rs = rHex + sHex;
|
|
105
|
+
const recovered = secp256k1.Signature
|
|
106
|
+
.fromCompact(rs)
|
|
107
|
+
.addRecoveryBit(recoveryBit)
|
|
108
|
+
.recoverPublicKey(hash);
|
|
109
|
+
|
|
110
|
+
const recoveredAddress = deriveAddress(recovered.toRawBytes(false));
|
|
111
|
+
|
|
112
|
+
// Always check recovered address matches the claimed signer in the receipt.
|
|
113
|
+
// Without this, an attacker can change receipt.signature.signer to any
|
|
114
|
+
// address and verifyReceipt still returns true.
|
|
115
|
+
const claimedSigner = receipt.signature.signer.toLowerCase();
|
|
116
|
+
const actual = recoveredAddress.toLowerCase();
|
|
117
|
+
if (claimedSigner !== actual) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Optional: also check against an externally expected signer
|
|
122
|
+
if (expectedSigner && expectedSigner.toLowerCase() !== actual) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return true;
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface ReceiptPayload {
|
|
2
|
+
v: 1;
|
|
3
|
+
kind: 'bolyra.auth';
|
|
4
|
+
/** Unix seconds when the decision was made. */
|
|
5
|
+
issuedAt: number;
|
|
6
|
+
/** Server identifier. */
|
|
7
|
+
issuer: string;
|
|
8
|
+
/** Signing key identifier for rotation. */
|
|
9
|
+
keyId: string;
|
|
10
|
+
|
|
11
|
+
subject: {
|
|
12
|
+
/** Root credential DID. */
|
|
13
|
+
rootDid: string;
|
|
14
|
+
/** Acting agent DID (leaf of delegation chain, or root if no chain). */
|
|
15
|
+
actingDid: string;
|
|
16
|
+
credentialCommitment: string;
|
|
17
|
+
effectiveCommitment: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
decision: {
|
|
21
|
+
allowed: boolean;
|
|
22
|
+
reasonCode?: string;
|
|
23
|
+
score: number;
|
|
24
|
+
/** Decimal string to avoid BigInt serialization issues. */
|
|
25
|
+
permissionBitmask: string;
|
|
26
|
+
chainDepth: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
proof: {
|
|
30
|
+
bundleVersion: 1 | 2;
|
|
31
|
+
/** Decimal string. */
|
|
32
|
+
nonce: string;
|
|
33
|
+
/** SHA-256 of canonical JSON of humanProof.proof. */
|
|
34
|
+
humanProofHash: string;
|
|
35
|
+
/** SHA-256 of canonical JSON of agentProof.proof. */
|
|
36
|
+
agentProofHash: string;
|
|
37
|
+
/** SHA-256 of canonical JSON of all public signals (human + agent). */
|
|
38
|
+
publicSignalsHash: string;
|
|
39
|
+
/** SHA-256 of canonical JSON of delegationChain (if v=2). */
|
|
40
|
+
delegationChainHash?: string;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SignedReceipt {
|
|
45
|
+
/** First 16 hex chars of payloadHash. */
|
|
46
|
+
id: string;
|
|
47
|
+
payload: ReceiptPayload;
|
|
48
|
+
signature: {
|
|
49
|
+
alg: 'ES256K';
|
|
50
|
+
keyId: string;
|
|
51
|
+
/** Ethereum address of the signer (hex, 0x-prefixed). */
|
|
52
|
+
signer: string;
|
|
53
|
+
/** keccak256 of canonical JSON payload (hex, 0x-prefixed). */
|
|
54
|
+
payloadHash: string;
|
|
55
|
+
/** r (32 bytes) + s (32 bytes) + v (1 byte) = 65 bytes (hex). */
|
|
56
|
+
value: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ReceiptSignerConfig {
|
|
61
|
+
issuer: string;
|
|
62
|
+
keyId: string;
|
|
63
|
+
/** secp256k1 private key, 32 bytes, hex-encoded, 0x-prefixed. */
|
|
64
|
+
privateKey: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AuthReceiptInput {
|
|
68
|
+
/** From BolyraAuthContext or equivalent. */
|
|
69
|
+
rootDid: string;
|
|
70
|
+
actingDid: string;
|
|
71
|
+
credentialCommitment: string;
|
|
72
|
+
effectiveCommitment: string;
|
|
73
|
+
allowed: boolean;
|
|
74
|
+
reasonCode?: string;
|
|
75
|
+
score: number;
|
|
76
|
+
permissionBitmask: string;
|
|
77
|
+
chainDepth: number;
|
|
78
|
+
/** Raw proof bundle for hashing. */
|
|
79
|
+
humanProof: { proof: unknown };
|
|
80
|
+
agentProof: { proof: unknown };
|
|
81
|
+
humanPublicSignals: string[];
|
|
82
|
+
agentPublicSignals: string[];
|
|
83
|
+
bundleVersion: 1 | 2;
|
|
84
|
+
nonce: string;
|
|
85
|
+
delegationChain?: unknown[];
|
|
86
|
+
}
|