@humanagencyp/hap-core 0.8.3 → 0.9.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 +4 -4
- package/dist/index.d.mts +258 -9
- package/dist/index.d.ts +258 -9
- package/dist/index.js +227 -5
- package/dist/index.mjs +213 -4
- package/package.json +3 -2
- package/src/did-key.ts +126 -0
- package/src/identity.ts +21 -5
- package/src/index.ts +3 -0
- package/src/mandate.ts +207 -0
- package/src/receipt.ts +74 -0
- package/src/types.ts +81 -5
package/dist/index.mjs
CHANGED
|
@@ -151,9 +151,9 @@ function computeIntentDisclosureHash(intentCiphertext, approversFrozen) {
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// src/identity.ts
|
|
154
|
-
function validateSubject(subject) {
|
|
154
|
+
function validateSubject(subject, opts) {
|
|
155
155
|
const errors = [];
|
|
156
|
-
const { did, assurance, method, trust_root, verifier, disclose
|
|
156
|
+
const { did, assurance, method, trust_root, verifier, disclose } = subject;
|
|
157
157
|
if (!did) errors.push("subject.did is required");
|
|
158
158
|
if (assurance === "low") {
|
|
159
159
|
if (method !== "self_declared") errors.push(`low assurance requires method "self_declared", got "${method}"`);
|
|
@@ -166,7 +166,10 @@ function validateSubject(subject) {
|
|
|
166
166
|
} else if (method === "eudi") {
|
|
167
167
|
if (trust_root !== "external") errors.push('eudi requires trust_root "external"');
|
|
168
168
|
if (!verifier) errors.push("eudi requires a verifier");
|
|
169
|
-
if (
|
|
169
|
+
if (opts?.ownerMandates !== void 0) {
|
|
170
|
+
const entry = opts.ownerMandates.find((m) => m.did === did && m.binding === "eudi");
|
|
171
|
+
if (!entry) errors.push('eudi requires an owner_mandates entry with binding "eudi" for this DID');
|
|
172
|
+
}
|
|
170
173
|
} else {
|
|
171
174
|
errors.push(`high assurance requires method "as_vouched" or "eudi", got "${method}"`);
|
|
172
175
|
}
|
|
@@ -869,10 +872,206 @@ function resolveCumulativeFields(request, profile, executionLog, now) {
|
|
|
869
872
|
}
|
|
870
873
|
return errors;
|
|
871
874
|
}
|
|
875
|
+
|
|
876
|
+
// src/did-key.ts
|
|
877
|
+
var B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
|
|
878
|
+
var B58_MAP = {};
|
|
879
|
+
for (let i = 0; i < B58_ALPHABET.length; i++) B58_MAP[B58_ALPHABET[i]] = i;
|
|
880
|
+
var ED25519_MULTICODEC = Uint8Array.from([237, 1]);
|
|
881
|
+
var ED25519_KEY_BYTES = 32;
|
|
882
|
+
function base58btcEncode(bytes) {
|
|
883
|
+
let zeros = 0;
|
|
884
|
+
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
|
885
|
+
const digits = [];
|
|
886
|
+
for (let i = zeros; i < bytes.length; i++) {
|
|
887
|
+
let carry = bytes[i];
|
|
888
|
+
for (let j = 0; j < digits.length; j++) {
|
|
889
|
+
carry += digits[j] * 256;
|
|
890
|
+
digits[j] = carry % 58;
|
|
891
|
+
carry = carry / 58 | 0;
|
|
892
|
+
}
|
|
893
|
+
while (carry > 0) {
|
|
894
|
+
digits.push(carry % 58);
|
|
895
|
+
carry = carry / 58 | 0;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
let out = "1".repeat(zeros);
|
|
899
|
+
for (let i = digits.length - 1; i >= 0; i--) out += B58_ALPHABET[digits[i]];
|
|
900
|
+
return out;
|
|
901
|
+
}
|
|
902
|
+
function base58btcDecode(s) {
|
|
903
|
+
let zeros = 0;
|
|
904
|
+
while (zeros < s.length && s[zeros] === "1") zeros++;
|
|
905
|
+
const bytes = [];
|
|
906
|
+
for (let i = zeros; i < s.length; i++) {
|
|
907
|
+
const val = B58_MAP[s[i]];
|
|
908
|
+
if (val === void 0) throw new Error(`invalid base58 character "${s[i]}"`);
|
|
909
|
+
let carry = val;
|
|
910
|
+
for (let j = 0; j < bytes.length; j++) {
|
|
911
|
+
carry += bytes[j] * 58;
|
|
912
|
+
bytes[j] = carry & 255;
|
|
913
|
+
carry >>= 8;
|
|
914
|
+
}
|
|
915
|
+
while (carry > 0) {
|
|
916
|
+
bytes.push(carry & 255);
|
|
917
|
+
carry >>= 8;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
const out = new Uint8Array(zeros + bytes.length);
|
|
921
|
+
for (let i = 0; i < bytes.length; i++) out[zeros + i] = bytes[bytes.length - 1 - i];
|
|
922
|
+
return out;
|
|
923
|
+
}
|
|
924
|
+
function encodeDidKey(publicKey) {
|
|
925
|
+
if (publicKey.length !== ED25519_KEY_BYTES) {
|
|
926
|
+
throw new Error(`INVALID_KEY: expected ${ED25519_KEY_BYTES} bytes, got ${publicKey.length}`);
|
|
927
|
+
}
|
|
928
|
+
const prefixed = new Uint8Array(ED25519_MULTICODEC.length + publicKey.length);
|
|
929
|
+
prefixed.set(ED25519_MULTICODEC, 0);
|
|
930
|
+
prefixed.set(publicKey, ED25519_MULTICODEC.length);
|
|
931
|
+
return `did:key:z${base58btcEncode(prefixed)}`;
|
|
932
|
+
}
|
|
933
|
+
function decodeDidKey(did) {
|
|
934
|
+
if (!did.startsWith("did:key:z")) {
|
|
935
|
+
throw new Error(`NOT_KEY_BEARING: ${JSON.stringify(did)} is not a base58btc did:key`);
|
|
936
|
+
}
|
|
937
|
+
let decoded;
|
|
938
|
+
try {
|
|
939
|
+
decoded = base58btcDecode(did.slice("did:key:z".length));
|
|
940
|
+
} catch (err) {
|
|
941
|
+
throw new Error(`NOT_KEY_BEARING: ${err.message}`);
|
|
942
|
+
}
|
|
943
|
+
if (decoded.length !== ED25519_MULTICODEC.length + ED25519_KEY_BYTES || decoded[0] !== ED25519_MULTICODEC[0] || decoded[1] !== ED25519_MULTICODEC[1]) {
|
|
944
|
+
throw new Error("NOT_KEY_BEARING: not an ed25519-pub multicodec payload");
|
|
945
|
+
}
|
|
946
|
+
return decoded.slice(ED25519_MULTICODEC.length);
|
|
947
|
+
}
|
|
948
|
+
function isKeyBearingDid(did) {
|
|
949
|
+
try {
|
|
950
|
+
decodeDidKey(did);
|
|
951
|
+
return true;
|
|
952
|
+
} catch {
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// src/mandate.ts
|
|
958
|
+
import * as ed2 from "@noble/ed25519";
|
|
959
|
+
var MandateError = class extends Error {
|
|
960
|
+
constructor(code, message) {
|
|
961
|
+
super(`${code}: ${message}`);
|
|
962
|
+
this.code = code;
|
|
963
|
+
this.name = "MandateError";
|
|
964
|
+
}
|
|
965
|
+
};
|
|
966
|
+
function buildMandateProjection(payload, entry) {
|
|
967
|
+
const { profile_id, bounds_hash, context_hash, execution_context_hash, gate_content_hashes, commitment_mode, expires_at } = payload;
|
|
968
|
+
if (!bounds_hash || !context_hash || !commitment_mode) {
|
|
969
|
+
throw new MandateError("MALFORMED_ATTESTATION", "mandate projection requires bounds_hash, context_hash and commitment_mode");
|
|
970
|
+
}
|
|
971
|
+
const projection = {
|
|
972
|
+
typ: "HAP-mandate",
|
|
973
|
+
version: "0.6",
|
|
974
|
+
profile_id,
|
|
975
|
+
owner_did: entry.did,
|
|
976
|
+
bounds_hash,
|
|
977
|
+
context_hash,
|
|
978
|
+
execution_context_hash,
|
|
979
|
+
gate_content_hashes,
|
|
980
|
+
commitment_mode,
|
|
981
|
+
expires_at,
|
|
982
|
+
nonce: entry.nonce
|
|
983
|
+
};
|
|
984
|
+
if (payload.intent_disclosure_hash !== void 0) {
|
|
985
|
+
projection.intent_disclosure_hash = payload.intent_disclosure_hash;
|
|
986
|
+
}
|
|
987
|
+
return projection;
|
|
988
|
+
}
|
|
989
|
+
function mandateSigningBytes(projection) {
|
|
990
|
+
return new TextEncoder().encode(canonicalize(projection));
|
|
991
|
+
}
|
|
992
|
+
function approvalSigningBytes(approval) {
|
|
993
|
+
return new TextEncoder().encode(canonicalize(approval));
|
|
994
|
+
}
|
|
995
|
+
function base64urlToBytes(s) {
|
|
996
|
+
const base64 = s.replace(/-/g, "+").replace(/_/g, "/");
|
|
997
|
+
const padding = base64.length % 4 === 0 ? "" : "=".repeat(4 - base64.length % 4);
|
|
998
|
+
return new Uint8Array(Buffer.from(base64 + padding, "base64"));
|
|
999
|
+
}
|
|
1000
|
+
function bytesToBase64url(bytes) {
|
|
1001
|
+
return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
1002
|
+
}
|
|
1003
|
+
async function signMandateProjection(projection, privateKey) {
|
|
1004
|
+
const sig = await ed2.signAsync(mandateSigningBytes(projection), privateKey);
|
|
1005
|
+
return bytesToBase64url(sig);
|
|
1006
|
+
}
|
|
1007
|
+
async function signApproval(approval, privateKey) {
|
|
1008
|
+
const sig = await ed2.signAsync(approvalSigningBytes(approval), privateKey);
|
|
1009
|
+
return bytesToBase64url(sig);
|
|
1010
|
+
}
|
|
1011
|
+
async function verifyOwnerMandate(attestation, entry) {
|
|
1012
|
+
const owners = attestation.payload.resolved_owners ?? [];
|
|
1013
|
+
if (!owners.includes(entry.did)) {
|
|
1014
|
+
throw new MandateError("OWNER_NOT_RESOLVED", `signing DID ${entry.did} is not in resolved_owners`);
|
|
1015
|
+
}
|
|
1016
|
+
if (entry.alg !== "EdDSA") {
|
|
1017
|
+
throw new MandateError("MANDATE_SIGNATURE_INVALID", `alg ${entry.alg} disagrees with the DID's key type (DID is authoritative)`);
|
|
1018
|
+
}
|
|
1019
|
+
let publicKey;
|
|
1020
|
+
try {
|
|
1021
|
+
publicKey = decodeDidKey(entry.did);
|
|
1022
|
+
} catch (err) {
|
|
1023
|
+
throw new MandateError("NOT_KEY_BEARING", err.message);
|
|
1024
|
+
}
|
|
1025
|
+
const projection = buildMandateProjection(attestation.payload, entry);
|
|
1026
|
+
const ok = await ed2.verifyAsync(base64urlToBytes(entry.signature), mandateSigningBytes(projection), publicKey);
|
|
1027
|
+
if (!ok) {
|
|
1028
|
+
throw new MandateError("MANDATE_SIGNATURE_INVALID", `owner mandate signature by ${entry.did} does not verify`);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
async function verifyOwnerMandates(attestation) {
|
|
1032
|
+
const entries = attestation.payload.owner_mandates ?? [];
|
|
1033
|
+
for (const entry of entries) {
|
|
1034
|
+
await verifyOwnerMandate(attestation, entry);
|
|
1035
|
+
}
|
|
1036
|
+
return entries;
|
|
1037
|
+
}
|
|
1038
|
+
async function verifyApproval(approval, signature, signerDid) {
|
|
1039
|
+
let publicKey;
|
|
1040
|
+
try {
|
|
1041
|
+
publicKey = decodeDidKey(signerDid);
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
throw new MandateError("NOT_KEY_BEARING", err.message);
|
|
1044
|
+
}
|
|
1045
|
+
const ok = await ed2.verifyAsync(base64urlToBytes(signature), approvalSigningBytes(approval), publicKey);
|
|
1046
|
+
if (!ok) {
|
|
1047
|
+
throw new MandateError("APPROVAL_SIGNATURE_INVALID", `approval signature by ${signerDid} does not verify`);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// src/receipt.ts
|
|
1052
|
+
import * as ed3 from "@noble/ed25519";
|
|
1053
|
+
async function verifyReceiptSignature(receipt, publicKeyHex) {
|
|
1054
|
+
const { signature, ...unsigned } = receipt;
|
|
1055
|
+
if (!signature) {
|
|
1056
|
+
throw new Error("INVALID_SIGNATURE: receipt carries no signature");
|
|
1057
|
+
}
|
|
1058
|
+
const bytes = new TextEncoder().encode(canonicalize(unsigned));
|
|
1059
|
+
const base64 = signature.replace(/-/g, "+").replace(/_/g, "/");
|
|
1060
|
+
const padding = base64.length % 4 === 0 ? "" : "=".repeat(4 - base64.length % 4);
|
|
1061
|
+
const sigBytes = new Uint8Array(Buffer.from(base64 + padding, "base64"));
|
|
1062
|
+
const publicKeyBytes = new Uint8Array(Buffer.from(publicKeyHex, "hex"));
|
|
1063
|
+
const ok = await ed3.verifyAsync(sigBytes, bytes, publicKeyBytes).catch(() => false);
|
|
1064
|
+
if (!ok) {
|
|
1065
|
+
throw new Error("INVALID_SIGNATURE: receipt signature verification failed");
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
872
1068
|
export {
|
|
873
1069
|
ContentBindingError,
|
|
1070
|
+
MandateError,
|
|
1071
|
+
approvalSigningBytes,
|
|
874
1072
|
attestationId,
|
|
875
1073
|
bindingAppliesTo,
|
|
1074
|
+
buildMandateProjection,
|
|
876
1075
|
canonicalBounds,
|
|
877
1076
|
canonicalContext,
|
|
878
1077
|
canonicalFrame,
|
|
@@ -889,26 +1088,36 @@ export {
|
|
|
889
1088
|
computeIntentHash,
|
|
890
1089
|
contentCanonicalBytes,
|
|
891
1090
|
decodeAttestationBlob,
|
|
1091
|
+
decodeDidKey,
|
|
892
1092
|
deriveIdentityLine,
|
|
893
1093
|
encodeAttestationBlob,
|
|
1094
|
+
encodeDidKey,
|
|
894
1095
|
frameHash,
|
|
895
1096
|
getAllProfiles,
|
|
896
1097
|
getProfile,
|
|
897
1098
|
intentDisclosureCanonicalBytes,
|
|
898
1099
|
isFieldBinding,
|
|
1100
|
+
isKeyBearingDid,
|
|
899
1101
|
isV4Attestation,
|
|
900
1102
|
listProfiles,
|
|
1103
|
+
mandateSigningBytes,
|
|
901
1104
|
registerProfile,
|
|
902
1105
|
selectBoundFields,
|
|
1106
|
+
signApproval,
|
|
1107
|
+
signMandateProjection,
|
|
903
1108
|
validateBoundsParams,
|
|
904
1109
|
validateContextParams,
|
|
905
1110
|
validateFrameParams,
|
|
906
1111
|
validateSubject,
|
|
907
1112
|
verify,
|
|
1113
|
+
verifyApproval,
|
|
908
1114
|
verifyAttestation,
|
|
909
1115
|
verifyAttestationSignature,
|
|
910
1116
|
verifyAttestationV4,
|
|
911
1117
|
verifyBoundsHash,
|
|
912
1118
|
verifyContextHash,
|
|
913
|
-
verifyFrameHash
|
|
1119
|
+
verifyFrameHash,
|
|
1120
|
+
verifyOwnerMandate,
|
|
1121
|
+
verifyOwnerMandates,
|
|
1122
|
+
verifyReceiptSignature
|
|
914
1123
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@humanagencyp/hap-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Core types, cryptographic primitives, and verification logic for the Human Agency Protocol",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -39,5 +39,6 @@
|
|
|
39
39
|
"tsup": "^8.0.0",
|
|
40
40
|
"typescript": "^5.3.0",
|
|
41
41
|
"vitest": "^1.0.0"
|
|
42
|
-
}
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://humanagencyprotocol.org"
|
|
43
44
|
}
|
package/src/did-key.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* did:key — encode/decode for KEY-BEARING DIDs (HAP v0.6).
|
|
3
|
+
*
|
|
4
|
+
* A real `did:key` is self-certifying: the public key IS the identifier, so
|
|
5
|
+
* substituting the key produces a DIFFERENT DID and no key directory is ever
|
|
6
|
+
* consulted. That property is what the Owner Mandate design stands on
|
|
7
|
+
* (protocol.md → "Identity DIDs vs signing DIDs"): a signing DID MUST be
|
|
8
|
+
* key-bearing, and verification uses the key carried in the identifier —
|
|
9
|
+
* deliberately, there is no `public_key` field anywhere on the wire.
|
|
10
|
+
*
|
|
11
|
+
* Format (W3C did:key method, Ed25519):
|
|
12
|
+
* did:key:z<base58btc( 0xed 0x01 ‖ 32-byte-public-key )>
|
|
13
|
+
* `z` is the multibase prefix for base58btc; `0xed 0x01` is the multicodec
|
|
14
|
+
* varint for ed25519-pub. The result always starts `did:key:z6Mk`.
|
|
15
|
+
*
|
|
16
|
+
* Scope: Ed25519 only — the one signing curve this protocol version uses.
|
|
17
|
+
* Anything else (including the reference implementation's legacy decorative
|
|
18
|
+
* `did:key:<uuid-fragment>` identifiers) decodes as NOT key-bearing.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
|
|
22
|
+
const B58_MAP: Record<string, number> = {};
|
|
23
|
+
for (let i = 0; i < B58_ALPHABET.length; i++) B58_MAP[B58_ALPHABET[i]] = i;
|
|
24
|
+
|
|
25
|
+
/** multicodec ed25519-pub (0xed) as unsigned varint, followed by key bytes. */
|
|
26
|
+
const ED25519_MULTICODEC = Uint8Array.from([0xed, 0x01]);
|
|
27
|
+
const ED25519_KEY_BYTES = 32;
|
|
28
|
+
|
|
29
|
+
function base58btcEncode(bytes: Uint8Array): string {
|
|
30
|
+
// Count leading zero bytes — they encode as leading '1's.
|
|
31
|
+
let zeros = 0;
|
|
32
|
+
while (zeros < bytes.length && bytes[zeros] === 0) zeros++;
|
|
33
|
+
|
|
34
|
+
const digits: number[] = [];
|
|
35
|
+
for (let i = zeros; i < bytes.length; i++) {
|
|
36
|
+
let carry = bytes[i];
|
|
37
|
+
for (let j = 0; j < digits.length; j++) {
|
|
38
|
+
carry += digits[j] * 256;
|
|
39
|
+
digits[j] = carry % 58;
|
|
40
|
+
carry = (carry / 58) | 0;
|
|
41
|
+
}
|
|
42
|
+
while (carry > 0) {
|
|
43
|
+
digits.push(carry % 58);
|
|
44
|
+
carry = (carry / 58) | 0;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
let out = '1'.repeat(zeros);
|
|
49
|
+
for (let i = digits.length - 1; i >= 0; i--) out += B58_ALPHABET[digits[i]];
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function base58btcDecode(s: string): Uint8Array {
|
|
54
|
+
let zeros = 0;
|
|
55
|
+
while (zeros < s.length && s[zeros] === '1') zeros++;
|
|
56
|
+
|
|
57
|
+
const bytes: number[] = [];
|
|
58
|
+
for (let i = zeros; i < s.length; i++) {
|
|
59
|
+
const val = B58_MAP[s[i]];
|
|
60
|
+
if (val === undefined) throw new Error(`invalid base58 character "${s[i]}"`);
|
|
61
|
+
let carry = val;
|
|
62
|
+
for (let j = 0; j < bytes.length; j++) {
|
|
63
|
+
carry += bytes[j] * 58;
|
|
64
|
+
bytes[j] = carry & 0xff;
|
|
65
|
+
carry >>= 8;
|
|
66
|
+
}
|
|
67
|
+
while (carry > 0) {
|
|
68
|
+
bytes.push(carry & 0xff);
|
|
69
|
+
carry >>= 8;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const out = new Uint8Array(zeros + bytes.length);
|
|
74
|
+
for (let i = 0; i < bytes.length; i++) out[zeros + i] = bytes[bytes.length - 1 - i];
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Encode a 32-byte Ed25519 public key as a key-bearing `did:key:z6Mk…`. */
|
|
79
|
+
export function encodeDidKey(publicKey: Uint8Array): string {
|
|
80
|
+
if (publicKey.length !== ED25519_KEY_BYTES) {
|
|
81
|
+
throw new Error(`INVALID_KEY: expected ${ED25519_KEY_BYTES} bytes, got ${publicKey.length}`);
|
|
82
|
+
}
|
|
83
|
+
const prefixed = new Uint8Array(ED25519_MULTICODEC.length + publicKey.length);
|
|
84
|
+
prefixed.set(ED25519_MULTICODEC, 0);
|
|
85
|
+
prefixed.set(publicKey, ED25519_MULTICODEC.length);
|
|
86
|
+
return `did:key:z${base58btcEncode(prefixed)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Decode a key-bearing `did:key` to its Ed25519 public key bytes.
|
|
91
|
+
*
|
|
92
|
+
* @throws Error prefixed `NOT_KEY_BEARING:` for anything that is not a valid
|
|
93
|
+
* base58btc-multibase Ed25519 did:key — including the legacy decorative
|
|
94
|
+
* `did:key:<uuid-fragment>` identifiers, other multibase prefixes, and other
|
|
95
|
+
* key types. The error name is the point: under the mandate design, a
|
|
96
|
+
* non-key-bearing signing DID must fail STRUCTURALLY, never fall back.
|
|
97
|
+
*/
|
|
98
|
+
export function decodeDidKey(did: string): Uint8Array {
|
|
99
|
+
if (!did.startsWith('did:key:z')) {
|
|
100
|
+
throw new Error(`NOT_KEY_BEARING: ${JSON.stringify(did)} is not a base58btc did:key`);
|
|
101
|
+
}
|
|
102
|
+
let decoded: Uint8Array;
|
|
103
|
+
try {
|
|
104
|
+
decoded = base58btcDecode(did.slice('did:key:z'.length));
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw new Error(`NOT_KEY_BEARING: ${(err as Error).message}`);
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
decoded.length !== ED25519_MULTICODEC.length + ED25519_KEY_BYTES ||
|
|
110
|
+
decoded[0] !== ED25519_MULTICODEC[0] ||
|
|
111
|
+
decoded[1] !== ED25519_MULTICODEC[1]
|
|
112
|
+
) {
|
|
113
|
+
throw new Error('NOT_KEY_BEARING: not an ed25519-pub multicodec payload');
|
|
114
|
+
}
|
|
115
|
+
return decoded.slice(ED25519_MULTICODEC.length);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** True iff the DID decodes as a key-bearing Ed25519 did:key. Never throws. */
|
|
119
|
+
export function isKeyBearingDid(did: string): boolean {
|
|
120
|
+
try {
|
|
121
|
+
decodeDidKey(did);
|
|
122
|
+
return true;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
package/src/identity.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* - {@link deriveIdentityLine} — the human-readable footer line for a subject.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type { Subject } from './types';
|
|
11
|
+
import type { OwnerMandate, Subject } from './types';
|
|
12
12
|
|
|
13
13
|
export interface SubjectValidation {
|
|
14
14
|
valid: boolean;
|
|
@@ -19,12 +19,25 @@ export interface SubjectValidation {
|
|
|
19
19
|
* Enforce the Identity-Assurance invariants on a single subject:
|
|
20
20
|
* - `self_declared` ⇒ `low` / `self`, no disclosed name.
|
|
21
21
|
* - `as_vouched` ⇒ `high` / `as`, `verifier` required.
|
|
22
|
-
* - `eudi` ⇒ `high` / `external`, `verifier`
|
|
22
|
+
* - `eudi` ⇒ `high` / `external`, `verifier` required — and, when the
|
|
23
|
+
* carrying attestation's `owner_mandates` are supplied, a corresponding
|
|
24
|
+
* entry with `binding:"eudi"` for this subject's DID.
|
|
23
25
|
* - `disclose.name` only at `assurance:"high"`.
|
|
26
|
+
*
|
|
27
|
+
* v0.6: `Subject.owner_signature` is deprecated and IGNORED here — it signed
|
|
28
|
+
* the identity claim, not the mandate (see the field's deprecation note). The
|
|
29
|
+
* signature-bearing object is the attestation's `owner_mandates` entry, which
|
|
30
|
+
* is payload-level context this per-subject check cannot see on its own; pass
|
|
31
|
+
* `opts.ownerMandates` to enforce the eudi ⇒ mandate-entry rule, omit it to
|
|
32
|
+
* validate subject shape alone (pre-0.6 callers keep their behaviour minus
|
|
33
|
+
* the retired owner_signature requirement).
|
|
24
34
|
*/
|
|
25
|
-
export function validateSubject(
|
|
35
|
+
export function validateSubject(
|
|
36
|
+
subject: Subject,
|
|
37
|
+
opts?: { ownerMandates?: OwnerMandate[] },
|
|
38
|
+
): SubjectValidation {
|
|
26
39
|
const errors: string[] = [];
|
|
27
|
-
const { did, assurance, method, trust_root, verifier, disclose
|
|
40
|
+
const { did, assurance, method, trust_root, verifier, disclose } = subject;
|
|
28
41
|
|
|
29
42
|
if (!did) errors.push('subject.did is required');
|
|
30
43
|
|
|
@@ -39,7 +52,10 @@ export function validateSubject(subject: Subject): SubjectValidation {
|
|
|
39
52
|
} else if (method === 'eudi') {
|
|
40
53
|
if (trust_root !== 'external') errors.push('eudi requires trust_root "external"');
|
|
41
54
|
if (!verifier) errors.push('eudi requires a verifier');
|
|
42
|
-
if (
|
|
55
|
+
if (opts?.ownerMandates !== undefined) {
|
|
56
|
+
const entry = opts.ownerMandates.find(m => m.did === did && m.binding === 'eudi');
|
|
57
|
+
if (!entry) errors.push('eudi requires an owner_mandates entry with binding "eudi" for this DID');
|
|
58
|
+
}
|
|
43
59
|
} else {
|
|
44
60
|
errors.push(`high assurance requires method "as_vouched" or "eudi", got "${method}"`);
|
|
45
61
|
}
|
package/src/index.ts
CHANGED
package/src/mandate.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner Mandate Signatures (HAP v0.6) — the `HAP-mandate` projection, the
|
|
3
|
+
* `HAP-approval` object, and their verification.
|
|
4
|
+
*
|
|
5
|
+
* The human signs BEFORE the AS does, so they cannot sign the finished
|
|
6
|
+
* attestation (`attestation_id`/`issued_at` do not exist yet). They sign a
|
|
7
|
+
* mandate PROJECTION: a canonical object every field of which is known at
|
|
8
|
+
* approval time and reconstructible from the finished attestation — so a
|
|
9
|
+
* verifier rebuilds it from the attestation it already holds and checks the
|
|
10
|
+
* signature with the key carried in the owner's DID. No side channel, no
|
|
11
|
+
* second fetch, no key directory. See protocol.md → "Owner Mandate Signatures".
|
|
12
|
+
*
|
|
13
|
+
* Verification here requires NO trust in the AS. What it cannot do is tell the
|
|
14
|
+
* verifier WHOSE key signed — confirming the DID belongs to the expected
|
|
15
|
+
* person is the out-of-band step (protocol.md → "Identity DIDs vs signing
|
|
16
|
+
* DIDs"), and it is the honest cost of cold verification.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as ed from '@noble/ed25519';
|
|
20
|
+
import type { Attestation, AttestationPayload, OwnerMandate } from './types';
|
|
21
|
+
import { canonicalize } from './canonicalize';
|
|
22
|
+
import { decodeDidKey } from './did-key';
|
|
23
|
+
|
|
24
|
+
/** The object the owner signs — a canonical projection of the mandate. */
|
|
25
|
+
export interface MandateProjection {
|
|
26
|
+
typ: 'HAP-mandate';
|
|
27
|
+
/** Projection/canonicalization version. A verifier MUST pin it. */
|
|
28
|
+
version: '0.6';
|
|
29
|
+
profile_id: string;
|
|
30
|
+
/** The signing owner's own DID. MUST be a member of `resolved_owners`. */
|
|
31
|
+
owner_did: string;
|
|
32
|
+
bounds_hash: string;
|
|
33
|
+
context_hash: string;
|
|
34
|
+
execution_context_hash: string;
|
|
35
|
+
gate_content_hashes: Record<string, string>;
|
|
36
|
+
/** Included iff the attestation carries one — binds ciphertext AND the
|
|
37
|
+
* frozen approver set, the swap this mechanism exists to stop. */
|
|
38
|
+
intent_disclosure_hash?: string;
|
|
39
|
+
commitment_mode: string;
|
|
40
|
+
/** The replay defence: the human signs how long the authority lives. */
|
|
41
|
+
expires_at: number;
|
|
42
|
+
nonce: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Per-action approval, signed by the owner in `review` mode. Signing a
|
|
46
|
+
* `reject` matters as much as a `commit`: a rejection the AS can discard is a
|
|
47
|
+
* rejection that never happened. */
|
|
48
|
+
export interface ApprovalObject {
|
|
49
|
+
typ: 'HAP-approval';
|
|
50
|
+
version: '0.6';
|
|
51
|
+
proposal_id: string;
|
|
52
|
+
attestation_id: string;
|
|
53
|
+
decision: 'commit' | 'reject';
|
|
54
|
+
/** What was approved: the receipt's contentHash where the profile binds
|
|
55
|
+
* content; otherwise sha256 over the JCS of the proposal's argument set. */
|
|
56
|
+
content_hash: string;
|
|
57
|
+
decided_at: number;
|
|
58
|
+
nonce: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class MandateError extends Error {
|
|
62
|
+
constructor(
|
|
63
|
+
public code:
|
|
64
|
+
| 'MANDATE_SIGNATURE_INVALID'
|
|
65
|
+
| 'APPROVAL_SIGNATURE_INVALID'
|
|
66
|
+
| 'NOT_KEY_BEARING'
|
|
67
|
+
| 'OWNER_NOT_RESOLVED'
|
|
68
|
+
| 'MALFORMED_ATTESTATION',
|
|
69
|
+
message: string,
|
|
70
|
+
) {
|
|
71
|
+
super(`${code}: ${message}`);
|
|
72
|
+
this.name = 'MandateError';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Rebuild the projection a given `owner_mandates` entry signed, from the
|
|
78
|
+
* attestation's own signed fields. Field absence is defined, not incidental:
|
|
79
|
+
* `intent_disclosure_hash` is included iff the attestation carries one.
|
|
80
|
+
*/
|
|
81
|
+
export function buildMandateProjection(
|
|
82
|
+
payload: AttestationPayload,
|
|
83
|
+
entry: Pick<OwnerMandate, 'did' | 'nonce'>,
|
|
84
|
+
): MandateProjection {
|
|
85
|
+
const { profile_id, bounds_hash, context_hash, execution_context_hash, gate_content_hashes, commitment_mode, expires_at } = payload;
|
|
86
|
+
if (!bounds_hash || !context_hash || !commitment_mode) {
|
|
87
|
+
throw new MandateError('MALFORMED_ATTESTATION', 'mandate projection requires bounds_hash, context_hash and commitment_mode');
|
|
88
|
+
}
|
|
89
|
+
const projection: MandateProjection = {
|
|
90
|
+
typ: 'HAP-mandate',
|
|
91
|
+
version: '0.6',
|
|
92
|
+
profile_id,
|
|
93
|
+
owner_did: entry.did,
|
|
94
|
+
bounds_hash,
|
|
95
|
+
context_hash,
|
|
96
|
+
execution_context_hash,
|
|
97
|
+
gate_content_hashes,
|
|
98
|
+
commitment_mode,
|
|
99
|
+
expires_at,
|
|
100
|
+
nonce: entry.nonce,
|
|
101
|
+
};
|
|
102
|
+
if (payload.intent_disclosure_hash !== undefined) {
|
|
103
|
+
projection.intent_disclosure_hash = payload.intent_disclosure_hash;
|
|
104
|
+
}
|
|
105
|
+
return projection;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The exact bytes an owner signs: RFC 8785 (JCS) canonical UTF-8. */
|
|
109
|
+
export function mandateSigningBytes(projection: MandateProjection): Uint8Array {
|
|
110
|
+
return new TextEncoder().encode(canonicalize(projection));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The exact bytes an owner signs for a per-action approval. */
|
|
114
|
+
export function approvalSigningBytes(approval: ApprovalObject): Uint8Array {
|
|
115
|
+
return new TextEncoder().encode(canonicalize(approval));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function base64urlToBytes(s: string): Uint8Array {
|
|
119
|
+
const base64 = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
120
|
+
const padding = base64.length % 4 === 0 ? '' : '='.repeat(4 - (base64.length % 4));
|
|
121
|
+
return new Uint8Array(Buffer.from(base64 + padding, 'base64'));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function bytesToBase64url(bytes: Uint8Array): string {
|
|
125
|
+
return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Sign a mandate projection with a raw Ed25519 private key — the `raw`
|
|
130
|
+
* binding: tests and CI only, no custody claim. WebAuthn (`webauthn`) and
|
|
131
|
+
* wallet (`eudi`) bindings sign the same bytes through their own custody;
|
|
132
|
+
* they are implemented by the platforms that hold those keys, not here.
|
|
133
|
+
*/
|
|
134
|
+
export async function signMandateProjection(
|
|
135
|
+
projection: MandateProjection,
|
|
136
|
+
privateKey: Uint8Array,
|
|
137
|
+
): Promise<string> {
|
|
138
|
+
const sig = await ed.signAsync(mandateSigningBytes(projection), privateKey);
|
|
139
|
+
return bytesToBase64url(sig);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Sign an approval object with a raw Ed25519 private key (`raw` binding). */
|
|
143
|
+
export async function signApproval(approval: ApprovalObject, privateKey: Uint8Array): Promise<string> {
|
|
144
|
+
const sig = await ed.signAsync(approvalSigningBytes(approval), privateKey);
|
|
145
|
+
return bytesToBase64url(sig);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Verify ONE `owner_mandates` entry against the attestation that carries it.
|
|
150
|
+
*
|
|
151
|
+
* Steps (protocol.md → "Verification procedure", steps 4 of 6): membership in
|
|
152
|
+
* `resolved_owners`, key-bearing DID, projection reconstruction, Ed25519 over
|
|
153
|
+
* JCS bytes. The DID is authoritative over `alg` — a disagreement is
|
|
154
|
+
* MANDATE_SIGNATURE_INVALID, never a fallback to the claimed algorithm.
|
|
155
|
+
*
|
|
156
|
+
* What this deliberately does NOT verify: that the DID belongs to the person
|
|
157
|
+
* the verifier expects (out-of-band, step 5) and the AS's own signature over
|
|
158
|
+
* the attestation (verifyAttestationSignature, step 1).
|
|
159
|
+
*/
|
|
160
|
+
export async function verifyOwnerMandate(attestation: Attestation, entry: OwnerMandate): Promise<void> {
|
|
161
|
+
const owners = attestation.payload.resolved_owners ?? [];
|
|
162
|
+
if (!owners.includes(entry.did)) {
|
|
163
|
+
throw new MandateError('OWNER_NOT_RESOLVED', `signing DID ${entry.did} is not in resolved_owners`);
|
|
164
|
+
}
|
|
165
|
+
if (entry.alg !== 'EdDSA') {
|
|
166
|
+
// The only key type a did:key carries in this protocol version is Ed25519;
|
|
167
|
+
// an entry claiming otherwise disagrees with its own DID.
|
|
168
|
+
throw new MandateError('MANDATE_SIGNATURE_INVALID', `alg ${entry.alg} disagrees with the DID's key type (DID is authoritative)`);
|
|
169
|
+
}
|
|
170
|
+
let publicKey: Uint8Array;
|
|
171
|
+
try {
|
|
172
|
+
publicKey = decodeDidKey(entry.did);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
throw new MandateError('NOT_KEY_BEARING', (err as Error).message);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const projection = buildMandateProjection(attestation.payload, entry);
|
|
178
|
+
const ok = await ed.verifyAsync(base64urlToBytes(entry.signature), mandateSigningBytes(projection), publicKey);
|
|
179
|
+
if (!ok) {
|
|
180
|
+
throw new MandateError('MANDATE_SIGNATURE_INVALID', `owner mandate signature by ${entry.did} does not verify`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Verify every `owner_mandates` entry an attestation carries. Resolves to the
|
|
185
|
+
* verified entries; an attestation with none resolves to `[]` (the v0.5
|
|
186
|
+
* posture — nothing to check, and nothing claimed). */
|
|
187
|
+
export async function verifyOwnerMandates(attestation: Attestation): Promise<OwnerMandate[]> {
|
|
188
|
+
const entries = attestation.payload.owner_mandates ?? [];
|
|
189
|
+
for (const entry of entries) {
|
|
190
|
+
await verifyOwnerMandate(attestation, entry);
|
|
191
|
+
}
|
|
192
|
+
return entries;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Verify an approval signature against a signer's key-bearing DID. */
|
|
196
|
+
export async function verifyApproval(approval: ApprovalObject, signature: string, signerDid: string): Promise<void> {
|
|
197
|
+
let publicKey: Uint8Array;
|
|
198
|
+
try {
|
|
199
|
+
publicKey = decodeDidKey(signerDid);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
throw new MandateError('NOT_KEY_BEARING', (err as Error).message);
|
|
202
|
+
}
|
|
203
|
+
const ok = await ed.verifyAsync(base64urlToBytes(signature), approvalSigningBytes(approval), publicKey);
|
|
204
|
+
if (!ok) {
|
|
205
|
+
throw new MandateError('APPROVAL_SIGNATURE_INVALID', `approval signature by ${signerDid} does not verify`);
|
|
206
|
+
}
|
|
207
|
+
}
|