@tinycloud/share-sdk 1.2.0-beta.1 → 1.2.0-beta.2
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/share-root.cjs +380 -54
- package/dist/share-root.cjs.map +1 -1
- package/dist/share-root.d.cts +166 -30
- package/dist/share-root.d.ts +166 -30
- package/dist/share-root.js +377 -54
- package/dist/share-root.js.map +1 -1
- package/package.json +1 -1
package/dist/share-root.js
CHANGED
|
@@ -5428,6 +5428,37 @@ function serialize(value) {
|
|
|
5428
5428
|
function canonicalize(value) {
|
|
5429
5429
|
return serialize(value);
|
|
5430
5430
|
}
|
|
5431
|
+
async function signCompactUcanAuthorization(input) {
|
|
5432
|
+
if (input.notBefore >= input.expiresAt || input.expiresAt - input.notBefore > 60) {
|
|
5433
|
+
throw new TypeError("compact invocation lifetime must be between one and 60 seconds");
|
|
5434
|
+
}
|
|
5435
|
+
const principal = input.issuerDid.split("#", 1)[0];
|
|
5436
|
+
const publicKey = ed25519PublicKeyFromDidKey(principal);
|
|
5437
|
+
const header = {
|
|
5438
|
+
alg: "EdDSA",
|
|
5439
|
+
jwk: { alg: "EdDSA", crv: "Ed25519", kty: "OKP", x: toBase64Url(publicKey) },
|
|
5440
|
+
typ: "JWT",
|
|
5441
|
+
ucv: "0.10.0"
|
|
5442
|
+
};
|
|
5443
|
+
const payload = {
|
|
5444
|
+
att: input.attenuation,
|
|
5445
|
+
aud: input.audienceDid,
|
|
5446
|
+
exp: input.expiresAt,
|
|
5447
|
+
fct: input.facts,
|
|
5448
|
+
iss: input.issuerDid.includes("#") ? input.issuerDid : `${principal}#${principal.slice("did:key:".length)}`,
|
|
5449
|
+
nbf: input.notBefore,
|
|
5450
|
+
nnc: input.nonce,
|
|
5451
|
+
prf: input.proofs
|
|
5452
|
+
};
|
|
5453
|
+
const protectedSegment = toBase64Url(new TextEncoder().encode(canonicalize(header)));
|
|
5454
|
+
const payloadSegment = toBase64Url(new TextEncoder().encode(canonicalize(payload)));
|
|
5455
|
+
const signingInput = new TextEncoder().encode(`${protectedSegment}.${payloadSegment}`);
|
|
5456
|
+
const signature = await input.sign(signingInput);
|
|
5457
|
+
if (signature.length !== 64) throw new TypeError("compact Authorization signature must be Ed25519");
|
|
5458
|
+
return verifyCompactUcanAuthorization(
|
|
5459
|
+
`${protectedSegment}.${payloadSegment}.${toBase64Url(signature)}`
|
|
5460
|
+
);
|
|
5461
|
+
}
|
|
5431
5462
|
function verifyCompactUcanAuthorization(authorization, expectedCid) {
|
|
5432
5463
|
if (/\s/.test(authorization)) throw new TypeError("compact Authorization contains whitespace");
|
|
5433
5464
|
const segments = authorization.split(".");
|
|
@@ -6618,44 +6649,57 @@ function parseTime(value, label, rootCid) {
|
|
|
6618
6649
|
if (!Number.isFinite(time)) throw new ShareRootError("malformed-root", `share root ${label} is missing or invalid`, { rootCid, mustRevoke: true });
|
|
6619
6650
|
return time;
|
|
6620
6651
|
}
|
|
6621
|
-
function
|
|
6652
|
+
function validateSignedOwnerDelegation(decoded, expected) {
|
|
6653
|
+
const { label } = expected;
|
|
6622
6654
|
const wider = { rootCid: decoded.cid, mustRevoke: true };
|
|
6623
6655
|
let issuer;
|
|
6624
6656
|
try {
|
|
6625
6657
|
issuer = parseOwnerDid(decoded.issuer);
|
|
6626
6658
|
} catch {
|
|
6627
|
-
throw new ShareRootError("issuer-mismatch",
|
|
6628
|
-
}
|
|
6629
|
-
if (!sameOwner(issuer, expected.owner)) throw new ShareRootError("issuer-mismatch",
|
|
6630
|
-
if (decoded.audience !== expected.keyDid) throw new ShareRootError("audience-mismatch",
|
|
6631
|
-
if (decoded.resources.length !== 1 || !decoded.resources[0].startsWith("urn:recap:")) throw new ShareRootError("resource-mismatch",
|
|
6632
|
-
if (decoded.reCapProofs.length !== 0) throw new ShareRootError("resource-mismatch",
|
|
6633
|
-
const wanted =
|
|
6634
|
-
|
|
6635
|
-
for (const resource of Object.keys(decoded.attenuation)) if (wanted[resource] === void 0) throw new ShareRootError("resource-mismatch", "share root grants a resource outside its scope", wider);
|
|
6659
|
+
throw new ShareRootError("issuer-mismatch", `${label} is not signed by a did:pkh owner`, wider);
|
|
6660
|
+
}
|
|
6661
|
+
if (!sameOwner(issuer, expected.owner)) throw new ShareRootError("issuer-mismatch", `${label} is signed by another owner`, wider);
|
|
6662
|
+
if (decoded.audience !== expected.keyDid) throw new ShareRootError("audience-mismatch", `${label} is not delegated to its key`, wider);
|
|
6663
|
+
if (decoded.resources.length !== 1 || !decoded.resources[0].startsWith("urn:recap:")) throw new ShareRootError("resource-mismatch", `${label} must carry exactly one ReCap resource`, wider);
|
|
6664
|
+
if (decoded.reCapProofs.length !== 0) throw new ShareRootError("resource-mismatch", `${label} must not have parent capabilities`, wider);
|
|
6665
|
+
const { wanted } = expected;
|
|
6666
|
+
for (const resource of Object.keys(decoded.attenuation)) if (wanted[resource] === void 0) throw new ShareRootError("resource-mismatch", `${label} grants a resource outside its scope`, wider);
|
|
6636
6667
|
for (const [resource, abilities] of Object.entries(wanted)) {
|
|
6637
6668
|
const granted = decoded.attenuation[resource];
|
|
6638
|
-
if (granted === void 0) throw new ShareRootError("resource-mismatch",
|
|
6669
|
+
if (granted === void 0) throw new ShareRootError("resource-mismatch", `${label} is missing one of its resources`, wider);
|
|
6639
6670
|
const names = Object.keys(granted);
|
|
6640
|
-
if (names.length !== abilities.length || abilities.some((ability) => !names.includes(ability))) throw new ShareRootError("ability-mismatch",
|
|
6671
|
+
if (names.length !== abilities.length || abilities.some((ability) => !names.includes(ability))) throw new ShareRootError("ability-mismatch", `${label} abilities differ from the request`, wider);
|
|
6641
6672
|
for (const ability of abilities) {
|
|
6642
6673
|
const caveats = granted[ability];
|
|
6643
6674
|
if (caveats.length !== 1 || typeof caveats[0] !== "object" || caveats[0] === null || Array.isArray(caveats[0]) || Object.keys(caveats[0]).length !== 0) {
|
|
6644
|
-
throw new ShareRootError("ability-mismatch",
|
|
6675
|
+
throw new ShareRootError("ability-mismatch", `${label} abilities carry unexpected caveats`, wider);
|
|
6645
6676
|
}
|
|
6646
6677
|
}
|
|
6647
6678
|
}
|
|
6648
6679
|
const issuedAt = parseTime(decoded.issuedAt, "issued-at time", decoded.cid);
|
|
6649
6680
|
const expiresAt = parseTime(decoded.expiresAt, "expiration time", decoded.cid);
|
|
6650
|
-
if (decoded.notBefore !== void 0 && parseTime(decoded.notBefore, "not-before time", decoded.cid) > expected.now) throw new ShareRootError("malformed-root",
|
|
6651
|
-
const cap =
|
|
6652
|
-
if (expiresAt - issuedAt > cap + SHARE_ROOT_CLOCK_SKEW_MS || expiresAt - expected.now > cap) throw new ShareRootError("lifetime-exceeds-cap", `${
|
|
6653
|
-
if (expiresAt <= expected.now) throw new ShareRootError("expired",
|
|
6681
|
+
if (decoded.notBefore !== void 0 && parseTime(decoded.notBefore, "not-before time", decoded.cid) > expected.now) throw new ShareRootError("malformed-root", `${label} is not valid yet`, { rootCid: decoded.cid });
|
|
6682
|
+
const cap = expected.maxLifetimeMs;
|
|
6683
|
+
if (expiresAt - issuedAt > cap + SHARE_ROOT_CLOCK_SKEW_MS || expiresAt - expected.now > cap) throw new ShareRootError("lifetime-exceeds-cap", `${label} lifetime exceeds its cap`, wider);
|
|
6684
|
+
if (expiresAt <= expected.now) throw new ShareRootError("expired", `${label} has expired`, { rootCid: decoded.cid });
|
|
6654
6685
|
if (expected.requestedExpiresAt !== void 0 && seconds(expiresAt) !== seconds(expected.requestedExpiresAt.getTime())) {
|
|
6655
|
-
throw new ShareRootError("expiry-mismatch",
|
|
6686
|
+
throw new ShareRootError("expiry-mismatch", `${label} was signed with a different expiry than requested`, { rootCid: decoded.cid, mustRevoke: expiresAt > expected.requestedExpiresAt.getTime() });
|
|
6656
6687
|
}
|
|
6657
6688
|
return { issuedAt: new Date(issuedAt), expiresAt: new Date(expiresAt) };
|
|
6658
6689
|
}
|
|
6690
|
+
function validateSignedRoot(decoded, expected) {
|
|
6691
|
+
const wanted = { [`${expected.spaceId}/kv/${SHARE_ROOT_PREFIX}`]: KV_ABILITIES };
|
|
6692
|
+
if (expected.scope === "kv+share-decrypt") wanted[shareNetworkId(expected.owner)] = [DECRYPT_ABILITY];
|
|
6693
|
+
return validateSignedOwnerDelegation(decoded, {
|
|
6694
|
+
label: `${expected.kind} root`,
|
|
6695
|
+
owner: expected.owner,
|
|
6696
|
+
keyDid: expected.keyDid,
|
|
6697
|
+
wanted,
|
|
6698
|
+
maxLifetimeMs: PROFILES[expected.kind].maxLifetimeMs,
|
|
6699
|
+
...expected.requestedExpiresAt === void 0 ? {} : { requestedExpiresAt: expected.requestedExpiresAt },
|
|
6700
|
+
now: expected.now
|
|
6701
|
+
});
|
|
6702
|
+
}
|
|
6659
6703
|
function assertLinkPath(path) {
|
|
6660
6704
|
if (typeof path !== "string" || !path.startsWith(SHARE_ROOT_PREFIX) || path.length === SHARE_ROOT_PREFIX.length || path.endsWith("/") || path.includes("//") || path.split("/").some((segment) => segment === "." || segment === "..") || /[\u0000-\u001f\u007f\\*?#]/.test(path)) throw new ShareRootError("link-out-of-scope", "link path must be one exact key under the share prefix");
|
|
6661
6705
|
}
|
|
@@ -6690,6 +6734,43 @@ function policyRootContents(input) {
|
|
|
6690
6734
|
}
|
|
6691
6735
|
return { facts, attenuation };
|
|
6692
6736
|
}
|
|
6737
|
+
function ownerPolicySigner(input) {
|
|
6738
|
+
const { ownerDid, authorityCid, notAfter, gate } = input;
|
|
6739
|
+
return {
|
|
6740
|
+
ownerDid,
|
|
6741
|
+
rootCid: authorityCid,
|
|
6742
|
+
notAfter,
|
|
6743
|
+
createOwnerRoot: async (root) => {
|
|
6744
|
+
const key = await gate();
|
|
6745
|
+
const now = input.now();
|
|
6746
|
+
const notBefore = root.notBefore instanceof Date ? root.notBefore.getTime() : Number.NaN;
|
|
6747
|
+
const expiry = root.expiresAt instanceof Date ? root.expiresAt.getTime() : Number.NaN;
|
|
6748
|
+
if (root.ownerDid !== ownerDid || root.role !== "policy-authority" && root.role !== "policy-enforcement") throw new ShareRootError("invalid-request", "policy root must be owned by this key");
|
|
6749
|
+
if (!Number.isFinite(notBefore) || !Number.isFinite(expiry) || notBefore >= expiry || expiry <= now) throw new ShareRootError("invalid-request", "policy root validity is invalid");
|
|
6750
|
+
if (expiry > notAfter.getTime() || expiry - notBefore > MAX_POLICY_ROOT_MS) throw new ShareRootError("lifetime-exceeds-cap", "policy root must not outlive its owner delegation or 31 days", { rootCid: authorityCid });
|
|
6751
|
+
for (const capability of root.capabilities) {
|
|
6752
|
+
if (!input.inScope(capability)) throw new ShareRootError("invalid-request", "policy root capability is outside the owner delegation");
|
|
6753
|
+
}
|
|
6754
|
+
const { facts, attenuation } = policyRootContents(root);
|
|
6755
|
+
const signed = await signShareKeyDelegation({
|
|
6756
|
+
key,
|
|
6757
|
+
audienceDid: root.audienceDid,
|
|
6758
|
+
attenuation,
|
|
6759
|
+
facts: [facts],
|
|
6760
|
+
proofs: [],
|
|
6761
|
+
notBefore: seconds(notBefore),
|
|
6762
|
+
expiresAt: seconds(expiry),
|
|
6763
|
+
nonce: toBase64Url(crypto.getRandomValues(new Uint8Array(16)))
|
|
6764
|
+
});
|
|
6765
|
+
return { cid: signed.cid, delegationHeader: { Authorization: signed.authorization } };
|
|
6766
|
+
},
|
|
6767
|
+
sign: async (digest) => {
|
|
6768
|
+
const key = await gate();
|
|
6769
|
+
if (!(digest instanceof Uint8Array) || digest.length !== 32) throw new ShareRootError("invalid-request", "policy signer signs 32-byte digests only");
|
|
6770
|
+
return key.sign(digest);
|
|
6771
|
+
}
|
|
6772
|
+
};
|
|
6773
|
+
}
|
|
6693
6774
|
function readPolicyOwnerAuthorityMode(features) {
|
|
6694
6775
|
if (!Array.isArray(features) || !features.includes(POLICY_OWNER_AUTHORITY_FEATURES.marker)) return void 0;
|
|
6695
6776
|
const enforce = features.includes(POLICY_OWNER_AUTHORITY_FEATURES.enforce);
|
|
@@ -6912,44 +6993,15 @@ var ShareRoot = class _ShareRoot {
|
|
|
6912
6993
|
*/
|
|
6913
6994
|
async policyRootSigner() {
|
|
6914
6995
|
await this.#assertPolicyGate();
|
|
6915
|
-
const gate = () => this.#assertPolicyGate();
|
|
6916
6996
|
const owner = parseOwnerDid(this.ownerDid);
|
|
6917
|
-
|
|
6918
|
-
return {
|
|
6997
|
+
return ownerPolicySigner({
|
|
6919
6998
|
ownerDid: this.keyDid,
|
|
6920
|
-
|
|
6921
|
-
notAfter,
|
|
6922
|
-
|
|
6923
|
-
|
|
6924
|
-
|
|
6925
|
-
|
|
6926
|
-
const expiry = input.expiresAt instanceof Date ? input.expiresAt.getTime() : Number.NaN;
|
|
6927
|
-
if (input.ownerDid !== this.keyDid || input.role !== "policy-authority" && input.role !== "policy-enforcement") throw new ShareRootError("invalid-request", "policy root must be owned by this share key");
|
|
6928
|
-
if (!Number.isFinite(notBefore) || !Number.isFinite(expiry) || notBefore >= expiry || expiry <= now) throw new ShareRootError("invalid-request", "policy root validity is invalid");
|
|
6929
|
-
if (expiry > notAfter.getTime() || expiry - notBefore > MAX_POLICY_ROOT_MS) throw new ShareRootError("lifetime-exceeds-cap", "policy root must not outlive its share root or 31 days", { rootCid: this.cid });
|
|
6930
|
-
for (const capability of input.capabilities) {
|
|
6931
|
-
const inScope = capability.kind === "encryption" ? isShareNetworkOf(capability.resource, owner) && capability.action === DECRYPT_ABILITY : capability.resource.startsWith(`${this.spaceId}/kv/${SHARE_ROOT_PREFIX}`) && capability.actions.every((action) => KV_ABILITIES.includes(action));
|
|
6932
|
-
if (!inScope) throw new ShareRootError("invalid-request", "policy root capability is outside the share root");
|
|
6933
|
-
}
|
|
6934
|
-
const { facts, attenuation } = policyRootContents(input);
|
|
6935
|
-
const root = await signShareKeyDelegation({
|
|
6936
|
-
key,
|
|
6937
|
-
audienceDid: input.audienceDid,
|
|
6938
|
-
attenuation,
|
|
6939
|
-
facts: [facts],
|
|
6940
|
-
proofs: [],
|
|
6941
|
-
notBefore: seconds(notBefore),
|
|
6942
|
-
expiresAt: seconds(expiry),
|
|
6943
|
-
nonce: toBase64Url(crypto.getRandomValues(new Uint8Array(16)))
|
|
6944
|
-
});
|
|
6945
|
-
return { cid: root.cid, delegationHeader: { Authorization: root.authorization } };
|
|
6946
|
-
},
|
|
6947
|
-
sign: async (digest) => {
|
|
6948
|
-
const key = await gate();
|
|
6949
|
-
if (!(digest instanceof Uint8Array) || digest.length !== 32) throw new ShareRootError("invalid-request", "policy signer signs 32-byte digests only");
|
|
6950
|
-
return key.sign(digest);
|
|
6951
|
-
}
|
|
6952
|
-
};
|
|
6999
|
+
authorityCid: this.cid,
|
|
7000
|
+
notAfter: new Date(this.expiresAt),
|
|
7001
|
+
gate: () => this.#assertPolicyGate(),
|
|
7002
|
+
now: () => this.#now(),
|
|
7003
|
+
inScope: (capability) => capability.kind === "encryption" ? isShareNetworkOf(capability.resource, owner) && capability.action === DECRYPT_ABILITY : capability.resource.startsWith(`${this.spaceId}/kv/${SHARE_ROOT_PREFIX}`) && capability.actions.every((action) => KV_ABILITIES.includes(action))
|
|
7004
|
+
});
|
|
6953
7005
|
}
|
|
6954
7006
|
/** Drop this root's key handle. It does not revoke the root: only the owner does that. */
|
|
6955
7007
|
clear() {
|
|
@@ -7007,6 +7059,274 @@ async function ensureShareRoot(input) {
|
|
|
7007
7059
|
}
|
|
7008
7060
|
return { root, rerooted: true };
|
|
7009
7061
|
}
|
|
7062
|
+
|
|
7063
|
+
// src/share-grant.ts
|
|
7064
|
+
var ACTIONS = {
|
|
7065
|
+
view: ["tinycloud.kv/get", "tinycloud.kv/metadata"],
|
|
7066
|
+
edit: ["tinycloud.kv/get", "tinycloud.kv/metadata", "tinycloud.kv/put"]
|
|
7067
|
+
};
|
|
7068
|
+
function wantedFor(spaceId, path, access, owner) {
|
|
7069
|
+
return { [`${spaceId}/kv/${path}`]: ACTIONS[access], [shareNetworkId(owner)]: [DECRYPT_ABILITY] };
|
|
7070
|
+
}
|
|
7071
|
+
var ShareGrant = class _ShareGrant {
|
|
7072
|
+
cid;
|
|
7073
|
+
ownerDid;
|
|
7074
|
+
spaceId;
|
|
7075
|
+
keyDid;
|
|
7076
|
+
nodeOrigin;
|
|
7077
|
+
path;
|
|
7078
|
+
access;
|
|
7079
|
+
issuedAt;
|
|
7080
|
+
expiresAt;
|
|
7081
|
+
#authorization;
|
|
7082
|
+
#key;
|
|
7083
|
+
#now;
|
|
7084
|
+
constructor(input) {
|
|
7085
|
+
this.cid = input.cid;
|
|
7086
|
+
this.ownerDid = input.ownerDid;
|
|
7087
|
+
this.spaceId = input.spaceId;
|
|
7088
|
+
this.keyDid = input.key.did;
|
|
7089
|
+
this.nodeOrigin = input.nodeOrigin;
|
|
7090
|
+
this.path = input.path;
|
|
7091
|
+
this.access = input.access;
|
|
7092
|
+
this.issuedAt = input.issuedAt;
|
|
7093
|
+
this.expiresAt = input.expiresAt;
|
|
7094
|
+
this.#authorization = input.authorization;
|
|
7095
|
+
this.#key = input.key;
|
|
7096
|
+
this.#now = input.now;
|
|
7097
|
+
}
|
|
7098
|
+
/**
|
|
7099
|
+
* Ask the owner for one per-share grant (one approval). Path, access,
|
|
7100
|
+
* expiry, space and Node origin are checked before the wallet is asked; the
|
|
7101
|
+
* signed CACAO is decoded and must match exactly, or the key is cleared and
|
|
7102
|
+
* the error carries the imported CID (`mustRevoke` when it may be wider).
|
|
7103
|
+
*/
|
|
7104
|
+
static async create(input) {
|
|
7105
|
+
const clock = input.now ?? Date.now;
|
|
7106
|
+
const now = clock();
|
|
7107
|
+
const owner = parseOwnerDid(input.owner.ownerDid);
|
|
7108
|
+
assertSpace(input.spaceId, owner, "share");
|
|
7109
|
+
const nodeOrigin = requireNodeOrigin(input.nodeOrigin);
|
|
7110
|
+
assertLinkPath(input.path);
|
|
7111
|
+
if (input.access !== "view" && input.access !== "edit") throw new ShareRootError("invalid-request", "share grant access must be view or edit");
|
|
7112
|
+
const expiry = input.expiresAt instanceof Date ? input.expiresAt.getTime() : Number.NaN;
|
|
7113
|
+
if (!Number.isFinite(expiry) || expiry <= now) throw new ShareRootError("invalid-request", "share grant expiry must be in the future");
|
|
7114
|
+
if (expiry - now > SHARE_ROOT_LINK_MAX_LIFETIME_MS) throw new ShareRootError("lifetime-exceeds-cap", "share grant lifetime exceeds 30 days");
|
|
7115
|
+
const key = await (input.keyFactory ?? (() => createDelegatedShareKey({ extractable: false })))();
|
|
7116
|
+
try {
|
|
7117
|
+
await assertUsableKey(key);
|
|
7118
|
+
} catch (error) {
|
|
7119
|
+
key.clear();
|
|
7120
|
+
throw error;
|
|
7121
|
+
}
|
|
7122
|
+
const permissions = [
|
|
7123
|
+
{ service: "tinycloud.kv", path: input.path, actions: [...ACTIONS[input.access]] },
|
|
7124
|
+
{ service: "tinycloud.encryption", path: shareNetworkId(owner), actions: [DECRYPT_ABILITY] }
|
|
7125
|
+
];
|
|
7126
|
+
let receipt;
|
|
7127
|
+
try {
|
|
7128
|
+
receipt = await input.owner.createOwnerDelegation({ delegateDid: key.did, spaceId: input.spaceId, expiresAt: input.expiresAt, permissions });
|
|
7129
|
+
} catch (error) {
|
|
7130
|
+
key.clear();
|
|
7131
|
+
throw error;
|
|
7132
|
+
}
|
|
7133
|
+
try {
|
|
7134
|
+
const authorization = new Uint8Array(receipt.signedDagCbor);
|
|
7135
|
+
const signedCid = ownerRootCacaoCid(authorization);
|
|
7136
|
+
if (signedCid !== receipt.delegationCid) throw new ShareRootError("malformed-root", "signed grant bytes do not match the imported delegation CID", { rootCid: receipt.delegationCid, signedCid, mustRevoke: true });
|
|
7137
|
+
let decoded;
|
|
7138
|
+
try {
|
|
7139
|
+
decoded = decodeOwnerRootCacao(authorization);
|
|
7140
|
+
} catch (error) {
|
|
7141
|
+
throw new ShareRootError("malformed-root", errorMessage(error), { rootCid: receipt.delegationCid, mustRevoke: true });
|
|
7142
|
+
}
|
|
7143
|
+
const times = validateSignedOwnerDelegation(decoded, {
|
|
7144
|
+
label: "per-share grant",
|
|
7145
|
+
owner,
|
|
7146
|
+
keyDid: key.did,
|
|
7147
|
+
wanted: wantedFor(input.spaceId, input.path, input.access, owner),
|
|
7148
|
+
maxLifetimeMs: SHARE_ROOT_LINK_MAX_LIFETIME_MS,
|
|
7149
|
+
requestedExpiresAt: input.expiresAt,
|
|
7150
|
+
now: clock()
|
|
7151
|
+
});
|
|
7152
|
+
return new _ShareGrant({ cid: decoded.cid, ownerDid: input.owner.ownerDid, spaceId: input.spaceId, nodeOrigin, path: input.path, access: input.access, ...times, authorization, key, now: clock });
|
|
7153
|
+
} catch (error) {
|
|
7154
|
+
key.clear();
|
|
7155
|
+
if (error instanceof ShareRootError) throw error;
|
|
7156
|
+
throw new ShareRootError("malformed-root", errorMessage(error), { rootCid: receipt.delegationCid, mustRevoke: true });
|
|
7157
|
+
}
|
|
7158
|
+
}
|
|
7159
|
+
/** Rebuild a stored grant with its key (e.g. a CLI 0600 key file); the signed bytes are checked again. */
|
|
7160
|
+
static async restore(input) {
|
|
7161
|
+
const { record } = input;
|
|
7162
|
+
const clock = input.now ?? Date.now;
|
|
7163
|
+
if (record.version !== 1 || record.access !== "view" && record.access !== "edit") throw new ShareRootError("malformed-root", "share grant record is not supported");
|
|
7164
|
+
if (record.keyDid !== input.key.did) throw new ShareRootError("audience-mismatch", "share grant record belongs to another key");
|
|
7165
|
+
const owner = parseOwnerDid(record.ownerDid);
|
|
7166
|
+
assertSpace(record.spaceId, owner, "share");
|
|
7167
|
+
const nodeOrigin = requireNodeOrigin(record.nodeOrigin);
|
|
7168
|
+
assertLinkPath(record.path);
|
|
7169
|
+
await assertUsableKey(input.key);
|
|
7170
|
+
let authorization;
|
|
7171
|
+
let decoded;
|
|
7172
|
+
try {
|
|
7173
|
+
authorization = fromBase64Url(record.authorization);
|
|
7174
|
+
decoded = decodeOwnerRootCacao(authorization, record.cid);
|
|
7175
|
+
} catch (error) {
|
|
7176
|
+
throw new ShareRootError("malformed-root", errorMessage(error), { rootCid: record.cid });
|
|
7177
|
+
}
|
|
7178
|
+
const times = validateSignedOwnerDelegation(decoded, {
|
|
7179
|
+
label: "per-share grant",
|
|
7180
|
+
owner,
|
|
7181
|
+
keyDid: input.key.did,
|
|
7182
|
+
wanted: wantedFor(record.spaceId, record.path, record.access, owner),
|
|
7183
|
+
maxLifetimeMs: SHARE_ROOT_LINK_MAX_LIFETIME_MS,
|
|
7184
|
+
now: clock()
|
|
7185
|
+
});
|
|
7186
|
+
return new _ShareGrant({ cid: decoded.cid, ownerDid: record.ownerDid, spaceId: record.spaceId, nodeOrigin, path: record.path, access: record.access, ...times, authorization, key: input.key, now: clock });
|
|
7187
|
+
}
|
|
7188
|
+
/** The `Authorization` value the Node knows this grant by (base64url CACAO). */
|
|
7189
|
+
get authorization() {
|
|
7190
|
+
return toBase64Url(this.#authorization);
|
|
7191
|
+
}
|
|
7192
|
+
/** The exact KV resource this grant covers. */
|
|
7193
|
+
get resource() {
|
|
7194
|
+
return `${this.spaceId}/kv/${this.path}`;
|
|
7195
|
+
}
|
|
7196
|
+
toRecord() {
|
|
7197
|
+
return { version: 1, cid: this.cid, authorization: this.authorization, spaceId: this.spaceId, ownerDid: this.ownerDid, keyDid: this.keyDid, nodeOrigin: this.nodeOrigin, path: this.path, access: this.access, expiresAt: this.expiresAt.toISOString() };
|
|
7198
|
+
}
|
|
7199
|
+
/**
|
|
7200
|
+
* Policy/v3 owner signing for this one share. Every call checks the key is
|
|
7201
|
+
* held and the grant is live. Policy roots must be owned by the per-share
|
|
7202
|
+
* key, expire no later than the grant, and name only this grant's exact
|
|
7203
|
+
* KV resource (with no action the grant lacks) and decrypt on `:share`.
|
|
7204
|
+
*/
|
|
7205
|
+
policySigner() {
|
|
7206
|
+
const owner = parseOwnerDid(this.ownerDid);
|
|
7207
|
+
const actions = ACTIONS[this.access];
|
|
7208
|
+
return ownerPolicySigner({
|
|
7209
|
+
ownerDid: this.keyDid,
|
|
7210
|
+
authorityCid: this.cid,
|
|
7211
|
+
notAfter: new Date(this.expiresAt),
|
|
7212
|
+
gate: async () => this.#activeKey(),
|
|
7213
|
+
now: this.#now,
|
|
7214
|
+
inScope: (capability) => capability.kind === "encryption" ? isShareNetworkOf(capability.resource, owner) && capability.action === DECRYPT_ABILITY : capability.resource === this.resource && capability.selector === "exact" && capability.actions.every((action) => actions.includes(action))
|
|
7215
|
+
});
|
|
7216
|
+
}
|
|
7217
|
+
/** `publishAddressedShare`'s `authority` for this share: the policy signer plus the caller's `registerPolicy`. */
|
|
7218
|
+
publishAuthority(registerPolicy) {
|
|
7219
|
+
return { ...this.policySigner(), registerPolicy };
|
|
7220
|
+
}
|
|
7221
|
+
/** Drop the per-share key. It does not revoke the grant: only the owner does that. */
|
|
7222
|
+
clear() {
|
|
7223
|
+
this.#key?.clear();
|
|
7224
|
+
this.#key = void 0;
|
|
7225
|
+
}
|
|
7226
|
+
#activeKey() {
|
|
7227
|
+
if (this.#key === void 0) throw new ShareRootError("key-cleared", "share grant key has been cleared", { rootCid: this.cid });
|
|
7228
|
+
if (this.#now() >= this.expiresAt.getTime()) throw new ShareRootError("expired", "share grant has expired", { rootCid: this.cid });
|
|
7229
|
+
return this.#key;
|
|
7230
|
+
}
|
|
7231
|
+
};
|
|
7232
|
+
|
|
7233
|
+
// src/share-network.ts
|
|
7234
|
+
import { sha256 as sha2564 } from "@noble/hashes/sha256";
|
|
7235
|
+
var NETWORK_CREATE_ABILITY = "tinycloud.encryption/network.create";
|
|
7236
|
+
var NETWORK_ADMIN_TYPE = "tinycloud.encryption.network-admin/v1";
|
|
7237
|
+
var NETWORK_GRANT_LIFETIME_MS = 10 * 6e4;
|
|
7238
|
+
function shareNetworkIdFor(ownerDid) {
|
|
7239
|
+
parseOwnerDid(ownerDid);
|
|
7240
|
+
return `urn:tinycloud:encryption:${ownerDid}:share`;
|
|
7241
|
+
}
|
|
7242
|
+
function hex(bytes2) {
|
|
7243
|
+
return [...bytes2].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
7244
|
+
}
|
|
7245
|
+
async function existingShareNetwork(nodeOrigin, networkId, ownerDid, fetchFn) {
|
|
7246
|
+
const response = await fetchFn(new URL(`/encryption/networks/${encodeURIComponent(networkId)}`, nodeOrigin), { redirect: "error" });
|
|
7247
|
+
if (response.status === 404) return void 0;
|
|
7248
|
+
if (!response.ok) throw new ShareRootError("invalid-request", `share network lookup failed: HTTP ${response.status}`);
|
|
7249
|
+
const body = await response.json();
|
|
7250
|
+
const descriptor = typeof body === "object" && body !== null && "descriptor" in body ? body.descriptor : body;
|
|
7251
|
+
if (typeof descriptor !== "object" || descriptor === null || !("networkId" in descriptor) || !("ownerDid" in descriptor) || !("state" in descriptor)) {
|
|
7252
|
+
throw new ShareRootError("invalid-request", "share network descriptor is malformed");
|
|
7253
|
+
}
|
|
7254
|
+
const { networkId: foundId, ownerDid: foundOwner, state } = descriptor;
|
|
7255
|
+
const owner = parseOwnerDid(ownerDid);
|
|
7256
|
+
if (typeof foundId !== "string" || !isShareNetworkOf(foundId, owner) || typeof foundOwner !== "string" || !sameOwner(parseOwnerDid(foundOwner), owner)) {
|
|
7257
|
+
throw new ShareRootError("invalid-request", "share network is not owned by this owner");
|
|
7258
|
+
}
|
|
7259
|
+
if (state === "revoked" || state === "failed") throw new ShareRootError("invalid-request", `share network is ${String(state)}`);
|
|
7260
|
+
return foundId;
|
|
7261
|
+
}
|
|
7262
|
+
async function ensureShareNetwork(input) {
|
|
7263
|
+
const clock = input.now ?? Date.now;
|
|
7264
|
+
const fetchFn = input.fetch ?? ((url, init) => globalThis.fetch(url, init));
|
|
7265
|
+
const owner = parseOwnerDid(input.owner.ownerDid);
|
|
7266
|
+
assertSpace(input.spaceId, owner, "share");
|
|
7267
|
+
const nodeOrigin = requireNodeOrigin(input.nodeOrigin);
|
|
7268
|
+
const networkId = shareNetworkIdFor(input.owner.ownerDid);
|
|
7269
|
+
const existing = await existingShareNetwork(nodeOrigin, networkId, input.owner.ownerDid, fetchFn);
|
|
7270
|
+
if (existing !== void 0) return { networkId: existing, approvals: 0 };
|
|
7271
|
+
const info = await (await fetchFn(new URL("/info", nodeOrigin), { redirect: "error" })).json();
|
|
7272
|
+
const nodeDid = typeof info === "object" && info !== null && "nodeId" in info ? info.nodeId : void 0;
|
|
7273
|
+
if (typeof nodeDid !== "string" || !nodeDid.startsWith("did:key:")) throw new ShareRootError("invalid-request", "node identity is unavailable");
|
|
7274
|
+
const key = await (input.keyFactory ?? (() => createDelegatedShareKey({ extractable: false })))();
|
|
7275
|
+
try {
|
|
7276
|
+
await assertUsableKey(key);
|
|
7277
|
+
const expiresAt = new Date(clock() + NETWORK_GRANT_LIFETIME_MS);
|
|
7278
|
+
const receipt = await input.owner.createOwnerDelegation({
|
|
7279
|
+
delegateDid: key.did,
|
|
7280
|
+
spaceId: input.spaceId,
|
|
7281
|
+
expiresAt,
|
|
7282
|
+
permissions: [{ service: "tinycloud.encryption", path: networkId, actions: [NETWORK_CREATE_ABILITY] }]
|
|
7283
|
+
});
|
|
7284
|
+
const authorization = new Uint8Array(receipt.signedDagCbor);
|
|
7285
|
+
const signedCid = ownerRootCacaoCid(authorization);
|
|
7286
|
+
if (signedCid !== receipt.delegationCid) throw new ShareRootError("malformed-root", "signed network grant bytes do not match the imported delegation CID", { rootCid: receipt.delegationCid, signedCid, mustRevoke: true });
|
|
7287
|
+
let decoded;
|
|
7288
|
+
try {
|
|
7289
|
+
decoded = decodeOwnerRootCacao(authorization);
|
|
7290
|
+
} catch (error) {
|
|
7291
|
+
throw new ShareRootError("malformed-root", errorMessage(error), { rootCid: receipt.delegationCid, mustRevoke: true });
|
|
7292
|
+
}
|
|
7293
|
+
validateSignedOwnerDelegation(decoded, {
|
|
7294
|
+
label: "share network grant",
|
|
7295
|
+
owner,
|
|
7296
|
+
keyDid: key.did,
|
|
7297
|
+
wanted: { [networkId]: [NETWORK_CREATE_ABILITY] },
|
|
7298
|
+
maxLifetimeMs: NETWORK_GRANT_LIFETIME_MS,
|
|
7299
|
+
requestedExpiresAt: expiresAt,
|
|
7300
|
+
now: clock()
|
|
7301
|
+
});
|
|
7302
|
+
const body = { name: "share", ownerDid: input.owner.ownerDid, threshold: { n: 1, t: 1 } };
|
|
7303
|
+
const canonicalBody = canonicalize(body);
|
|
7304
|
+
const now = seconds(clock());
|
|
7305
|
+
const invocation = await signCompactUcanAuthorization({
|
|
7306
|
+
issuerDid: key.did,
|
|
7307
|
+
audienceDid: nodeDid,
|
|
7308
|
+
attenuation: { [networkId]: { [NETWORK_CREATE_ABILITY]: [{}] } },
|
|
7309
|
+
facts: [{ type: NETWORK_ADMIN_TYPE, targetNode: nodeDid, networkId, bodyHash: hex(sha2564(new TextEncoder().encode(canonicalBody))), action: NETWORK_CREATE_ABILITY }],
|
|
7310
|
+
proofs: [decoded.cid],
|
|
7311
|
+
notBefore: now,
|
|
7312
|
+
expiresAt: now + 60,
|
|
7313
|
+
nonce: toBase64Url(crypto.getRandomValues(new Uint8Array(16))),
|
|
7314
|
+
sign: (bytes2) => key.sign(bytes2)
|
|
7315
|
+
});
|
|
7316
|
+
const response = await fetchFn(new URL("/encryption/networks", nodeOrigin), {
|
|
7317
|
+
method: "POST",
|
|
7318
|
+
redirect: "error",
|
|
7319
|
+
headers: { Authorization: invocation.authorization, "content-type": "application/json" },
|
|
7320
|
+
body: canonicalBody
|
|
7321
|
+
});
|
|
7322
|
+
if (!response.ok && response.status !== 409) throw new ShareRootError("invalid-request", `share network creation failed: HTTP ${response.status} ${(await response.text()).slice(0, 120)}`);
|
|
7323
|
+
const created = await existingShareNetwork(nodeOrigin, networkId, input.owner.ownerDid, fetchFn);
|
|
7324
|
+
if (created === void 0) throw new ShareRootError("invalid-request", "share network was not created");
|
|
7325
|
+
return { networkId: created, approvals: 1 };
|
|
7326
|
+
} finally {
|
|
7327
|
+
key.clear();
|
|
7328
|
+
}
|
|
7329
|
+
}
|
|
7010
7330
|
export {
|
|
7011
7331
|
AGENT_SHARE_ROOT_MAX_LIFETIME_MS,
|
|
7012
7332
|
POLICY_OWNER_AUTHORITY_FEATURES,
|
|
@@ -7016,15 +7336,18 @@ export {
|
|
|
7016
7336
|
SHARE_ROOT_MAX_LIFETIME_MS,
|
|
7017
7337
|
SHARE_ROOT_PREFIX,
|
|
7018
7338
|
SHARE_ROOT_REQUEST_MARGIN_MS,
|
|
7339
|
+
ShareGrant,
|
|
7019
7340
|
ShareRoot,
|
|
7020
7341
|
ShareRootError,
|
|
7021
7342
|
assertNonExtractableShareKey,
|
|
7022
7343
|
createPolicyOwnerModeProbe,
|
|
7023
7344
|
decodeOwnerRootCacao,
|
|
7024
7345
|
defaultPolicyOwnerModeProbe,
|
|
7346
|
+
ensureShareNetwork,
|
|
7025
7347
|
ensureShareRoot,
|
|
7026
7348
|
ownerRootCacaoCid,
|
|
7027
7349
|
readPolicyOwnerAuthorityMode,
|
|
7350
|
+
shareNetworkIdFor,
|
|
7028
7351
|
signShareKeyDelegation
|
|
7029
7352
|
};
|
|
7030
7353
|
//# sourceMappingURL=share-root.js.map
|