@tokono.ma/firkin 0.2.0 → 0.4.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 +86 -15
- package/dist/bao.d.ts +25 -0
- package/dist/bao.js +225 -0
- package/dist/client.d.ts +240 -28
- package/dist/client.js +356 -65
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/plc.d.ts +87 -0
- package/dist/plc.js +97 -0
- package/dist/rasl.d.ts +30 -0
- package/dist/rasl.js +107 -0
- package/dist/ucan.d.ts +7 -2
- package/dist/ucan.js +4 -3
- package/dist/webauthn.d.ts +56 -9
- package/dist/webauthn.js +83 -22
- package/package.json +5 -4
package/dist/plc.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { PlcSessionRecord } from "./client.ts";
|
|
2
|
+
/** The record collection Firkin verifies (`ma.tokono.firkin.session`). */
|
|
3
|
+
export declare const PLC_SESSION_COLLECTION = "ma.tokono.firkin.session";
|
|
4
|
+
/** What `Firkin.attestPlc` takes. */
|
|
5
|
+
export interface PlcAttestationMaterial {
|
|
6
|
+
did: string;
|
|
7
|
+
/** PLC operations, genesis first (base64url DAG-CBOR). */
|
|
8
|
+
ops: string[];
|
|
9
|
+
/** The signed repo commit block. */
|
|
10
|
+
commit: string;
|
|
11
|
+
/** The session record. */
|
|
12
|
+
record: string;
|
|
13
|
+
/** MST node blocks proving the record's inclusion. */
|
|
14
|
+
proof: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The subset of an ATProto agent this needs. `@atproto/api`'s `Agent` (or an
|
|
18
|
+
* OAuth session's agent) satisfies it structurally.
|
|
19
|
+
*/
|
|
20
|
+
export interface PlcAgentLike {
|
|
21
|
+
com: {
|
|
22
|
+
atproto: {
|
|
23
|
+
repo: {
|
|
24
|
+
putRecord(input: {
|
|
25
|
+
repo: string;
|
|
26
|
+
collection: string;
|
|
27
|
+
rkey: string;
|
|
28
|
+
record: unknown;
|
|
29
|
+
validate?: boolean;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
data: {
|
|
32
|
+
commit?: {
|
|
33
|
+
cid?: string;
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
}>;
|
|
37
|
+
};
|
|
38
|
+
sync: {
|
|
39
|
+
getLatestCommit(input: {
|
|
40
|
+
did: string;
|
|
41
|
+
}): Promise<{
|
|
42
|
+
data: {
|
|
43
|
+
cid: string;
|
|
44
|
+
};
|
|
45
|
+
}>;
|
|
46
|
+
getBlocks(input: {
|
|
47
|
+
did: string;
|
|
48
|
+
cids: string[];
|
|
49
|
+
}): Promise<{
|
|
50
|
+
data: Uint8Array;
|
|
51
|
+
}>;
|
|
52
|
+
getRecord(input: {
|
|
53
|
+
did: string;
|
|
54
|
+
collection: string;
|
|
55
|
+
rkey: string;
|
|
56
|
+
}): Promise<{
|
|
57
|
+
data: {
|
|
58
|
+
cid: string;
|
|
59
|
+
value: unknown;
|
|
60
|
+
proof?: Uint8Array[];
|
|
61
|
+
};
|
|
62
|
+
}>;
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Write `record` into the account's repo and collect the attestation material.
|
|
69
|
+
*
|
|
70
|
+
* `record` is what `plcSessionRecord(...)` returns; its `session` field is used
|
|
71
|
+
* as the record key, which is why the service can find it deterministically.
|
|
72
|
+
*
|
|
73
|
+
* The commit and the MST proof must describe the *same* revision; a concurrent
|
|
74
|
+
* write between the two fetches is detected (the proof must contain the
|
|
75
|
+
* commit's `data` root) and retried.
|
|
76
|
+
*/
|
|
77
|
+
export declare function collectPlcAttestation(opts: {
|
|
78
|
+
agent: PlcAgentLike;
|
|
79
|
+
did: string;
|
|
80
|
+
record: PlcSessionRecord;
|
|
81
|
+
collection?: string;
|
|
82
|
+
/** Defaults to `https://plc.directory`. */
|
|
83
|
+
plcDirectory?: string;
|
|
84
|
+
fetchImpl?: typeof fetch;
|
|
85
|
+
/** Fetch retries when the repo advances mid-collection (default 5). */
|
|
86
|
+
attempts?: number;
|
|
87
|
+
}): Promise<PlcAttestationMaterial>;
|
package/dist/plc.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// `did:plc` attestation material: write the session record into the account's
|
|
2
|
+
// repo with an ATProto agent, then collect everything Firkin verifies.
|
|
3
|
+
//
|
|
4
|
+
// Firkin never talks to a PDS or a PLC directory. The client gathers:
|
|
5
|
+
// - the PLC operation log (`plc.directory/<did>/log`),
|
|
6
|
+
// - the signed repo commit containing the record,
|
|
7
|
+
// - the record itself,
|
|
8
|
+
// - the MST nodes proving the record is in that commit.
|
|
9
|
+
// Everything is sent as base64url DAG-CBOR; `Firkin.attestPlc` posts it.
|
|
10
|
+
import * as dagcbor from "@ipld/dag-cbor";
|
|
11
|
+
import { cidForBytes, CODEC_DAG_CBOR } from "./cid.js";
|
|
12
|
+
import { parseCarBlocks } from "./client.js";
|
|
13
|
+
/** The record collection Firkin verifies (`ma.tokono.firkin.session`). */
|
|
14
|
+
export const PLC_SESSION_COLLECTION = "ma.tokono.firkin.session";
|
|
15
|
+
/**
|
|
16
|
+
* Write `record` into the account's repo and collect the attestation material.
|
|
17
|
+
*
|
|
18
|
+
* `record` is what `plcSessionRecord(...)` returns; its `session` field is used
|
|
19
|
+
* as the record key, which is why the service can find it deterministically.
|
|
20
|
+
*
|
|
21
|
+
* The commit and the MST proof must describe the *same* revision; a concurrent
|
|
22
|
+
* write between the two fetches is detected (the proof must contain the
|
|
23
|
+
* commit's `data` root) and retried.
|
|
24
|
+
*/
|
|
25
|
+
export async function collectPlcAttestation(opts) {
|
|
26
|
+
const collection = opts.collection ?? PLC_SESSION_COLLECTION;
|
|
27
|
+
// The PLC endpoint takes the DID verbatim (`/did:plc:…/log`); validate the
|
|
28
|
+
// syntax rather than escaping it into a path.
|
|
29
|
+
if (!/^did:plc:[a-z2-7]{24}$/.test(opts.did)) {
|
|
30
|
+
throw new Error(`not a did:plc: ${opts.did}`);
|
|
31
|
+
}
|
|
32
|
+
const session = opts.record.session;
|
|
33
|
+
if (typeof session !== "string" || session.length === 0) {
|
|
34
|
+
throw new Error("record.session (the did:key) is required");
|
|
35
|
+
}
|
|
36
|
+
const rkey = session;
|
|
37
|
+
await opts.agent.com.atproto.repo.putRecord({
|
|
38
|
+
repo: opts.did,
|
|
39
|
+
collection,
|
|
40
|
+
rkey,
|
|
41
|
+
record: opts.record,
|
|
42
|
+
});
|
|
43
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
44
|
+
const directory = opts.plcDirectory ?? "https://plc.directory";
|
|
45
|
+
const logRes = await fetchImpl(`${directory}/${opts.did}/log`);
|
|
46
|
+
if (!logRes.ok) {
|
|
47
|
+
throw new Error(`PLC log for ${opts.did}: ${logRes.status}`);
|
|
48
|
+
}
|
|
49
|
+
const log = (await logRes.json());
|
|
50
|
+
if (!Array.isArray(log) || log.length === 0) {
|
|
51
|
+
throw new Error(`PLC log for ${opts.did} is empty`);
|
|
52
|
+
}
|
|
53
|
+
const ops = log.map((op) => base64url(dagcbor.encode(op)));
|
|
54
|
+
for (let attempt = 0; attempt < (opts.attempts ?? 5); attempt++) {
|
|
55
|
+
// Everything below is public; the agent's auth is simply unused.
|
|
56
|
+
const latest = await opts.agent.com.atproto.sync.getLatestCommit({
|
|
57
|
+
did: opts.did,
|
|
58
|
+
});
|
|
59
|
+
const car = await opts.agent.com.atproto.sync.getBlocks({
|
|
60
|
+
did: opts.did,
|
|
61
|
+
cids: [latest.data.cid],
|
|
62
|
+
});
|
|
63
|
+
const commitBytes = parseCarBlocks(new Uint8Array(car.data)).get(latest.data.cid);
|
|
64
|
+
if (!commitBytes)
|
|
65
|
+
continue;
|
|
66
|
+
const commit = dagcbor.decode(commitBytes);
|
|
67
|
+
const rec = await opts.agent.com.atproto.sync.getRecord({
|
|
68
|
+
did: opts.did,
|
|
69
|
+
collection,
|
|
70
|
+
rkey,
|
|
71
|
+
});
|
|
72
|
+
const proof = rec.data.proof ?? [];
|
|
73
|
+
if (!commit.data || proof.length === 0)
|
|
74
|
+
continue;
|
|
75
|
+
// The proof must belong to this commit's tree. The node order is not
|
|
76
|
+
// specified, so look for the declared root anywhere in the set. (`proof`
|
|
77
|
+
// entries are raw DAG-CBOR blocks, hashed as-is.)
|
|
78
|
+
const root = commit.data.toString();
|
|
79
|
+
const roots = proof.map((node) => cidForBytes(node, { codec: CODEC_DAG_CBOR }));
|
|
80
|
+
if (!roots.includes(root))
|
|
81
|
+
continue;
|
|
82
|
+
return {
|
|
83
|
+
did: opts.did,
|
|
84
|
+
ops,
|
|
85
|
+
commit: base64url(commitBytes),
|
|
86
|
+
record: base64url(dagcbor.encode(rec.data.value)),
|
|
87
|
+
proof: proof.map((node) => base64url(node)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
throw new Error("the repo kept advancing while collecting the attestation; retry");
|
|
91
|
+
}
|
|
92
|
+
function base64url(bytes) {
|
|
93
|
+
let binary = "";
|
|
94
|
+
for (const byte of bytes)
|
|
95
|
+
binary += String.fromCharCode(byte);
|
|
96
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
97
|
+
}
|
package/dist/rasl.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface RaslUrl {
|
|
2
|
+
/** The DASL/BDASL CID (the URL's authority). */
|
|
3
|
+
cid: string;
|
|
4
|
+
/** Hosts that may serve it (`?hint=` values, invalid ones dropped). */
|
|
5
|
+
hints: string[];
|
|
6
|
+
}
|
|
7
|
+
/** Build a `rasl://` URL for a CID and optional hint hosts. */
|
|
8
|
+
export declare function raslUrl(cid: string, hints?: string[]): string;
|
|
9
|
+
/** Parse a `rasl://` URL per the spec: the host is the CID, `hint` entries
|
|
10
|
+
* are the (valid) hint hosts; everything else, including the path, is
|
|
11
|
+
* ignored. */
|
|
12
|
+
export declare function parseRasl(url: string): RaslUrl;
|
|
13
|
+
/** Check that `bytes` hash to `cid` (SHA-256 and BLAKE3 multihashes).
|
|
14
|
+
* Compares the digest, so CIDv0/CIDv1 spelling doesn't matter. */
|
|
15
|
+
export declare function verifyCid(cid: string, bytes: Uint8Array): boolean;
|
|
16
|
+
export interface ResolveOptions {
|
|
17
|
+
fetchImpl?: typeof fetch;
|
|
18
|
+
/** Cancels the whole resolution (including in-flight hint requests). */
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a `rasl://` URL: request every hint's
|
|
23
|
+
* `https://<hint>/.well-known/rasl/<cid>` in parallel (statelessly), take the
|
|
24
|
+
* first response whose bytes hash to the CID. Failing or mismatching hints
|
|
25
|
+
* are skipped; if none verify, it throws.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveRasl(url: string, opts?: ResolveOptions): Promise<{
|
|
28
|
+
cid: string;
|
|
29
|
+
bytes: Uint8Array;
|
|
30
|
+
}>;
|
package/dist/rasl.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// RASL (Retrieval of Arbitrary Structures & Links) — https://dasl.ing/rasl.html
|
|
2
|
+
//
|
|
3
|
+
// A `rasl://<cid>/?hint=<host>&hint=<host>` URL names a CID plus optional
|
|
4
|
+
// places to fetch it. Retrieval is stateless (no cookies, no credentials —
|
|
5
|
+
// the hash is the authority) and self-verifying: whatever comes back must
|
|
6
|
+
// hash to the CID.
|
|
7
|
+
import { sha256 } from "@noble/hashes/sha2";
|
|
8
|
+
import { blake3 } from "@noble/hashes/blake3";
|
|
9
|
+
import { CID } from "multiformats";
|
|
10
|
+
import { HASH_BLAKE3, HASH_SHA256 } from "./cid.js";
|
|
11
|
+
/** A valid `https` host: DNS labels + optional port (IPv6 literals too). */
|
|
12
|
+
const HOST_RE = /^(\[[0-9a-fA-F:]+\]|[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*)(:\d{1,5})?$/;
|
|
13
|
+
/** Build a `rasl://` URL for a CID and optional hint hosts. */
|
|
14
|
+
export function raslUrl(cid, hints = []) {
|
|
15
|
+
const query = hints.map((hint) => `hint=${encodeURIComponent(hint)}`).join("&");
|
|
16
|
+
return `rasl://${cid}/${query ? `?${query}` : ""}`;
|
|
17
|
+
}
|
|
18
|
+
/** Parse a `rasl://` URL per the spec: the host is the CID, `hint` entries
|
|
19
|
+
* are the (valid) hint hosts; everything else, including the path, is
|
|
20
|
+
* ignored. */
|
|
21
|
+
export function parseRasl(url) {
|
|
22
|
+
const parsed = new URL(url);
|
|
23
|
+
if (parsed.protocol !== "rasl:") {
|
|
24
|
+
throw new Error(`not a rasl:// URL: ${url}`);
|
|
25
|
+
}
|
|
26
|
+
const cid = parsed.host;
|
|
27
|
+
if (!cid)
|
|
28
|
+
throw new Error(`rasl:// URL lacks a CID: ${url}`);
|
|
29
|
+
CID.parse(cid); // must be a well-formed CID
|
|
30
|
+
const hints = parsed.searchParams
|
|
31
|
+
.getAll("hint")
|
|
32
|
+
.map((hint) => hint.trim())
|
|
33
|
+
.filter((hint) => HOST_RE.test(hint));
|
|
34
|
+
return { cid, hints };
|
|
35
|
+
}
|
|
36
|
+
/** Check that `bytes` hash to `cid` (SHA-256 and BLAKE3 multihashes).
|
|
37
|
+
* Compares the digest, so CIDv0/CIDv1 spelling doesn't matter. */
|
|
38
|
+
export function verifyCid(cid, bytes) {
|
|
39
|
+
const parsed = CID.parse(cid);
|
|
40
|
+
const digest = parsed.multihash.code === HASH_SHA256
|
|
41
|
+
? sha256(bytes)
|
|
42
|
+
: parsed.multihash.code === HASH_BLAKE3
|
|
43
|
+
? blake3(bytes)
|
|
44
|
+
: undefined;
|
|
45
|
+
if (!digest || digest.length !== parsed.multihash.size)
|
|
46
|
+
return false;
|
|
47
|
+
return parsed.multihash.digest.every((byte, i) => byte === digest[i]);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resolve a `rasl://` URL: request every hint's
|
|
51
|
+
* `https://<hint>/.well-known/rasl/<cid>` in parallel (statelessly), take the
|
|
52
|
+
* first response whose bytes hash to the CID. Failing or mismatching hints
|
|
53
|
+
* are skipped; if none verify, it throws.
|
|
54
|
+
*/
|
|
55
|
+
export async function resolveRasl(url, opts = {}) {
|
|
56
|
+
const { cid, hints } = parseRasl(url);
|
|
57
|
+
if (hints.length === 0) {
|
|
58
|
+
throw new Error("rasl:// URL has no usable hints");
|
|
59
|
+
}
|
|
60
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
61
|
+
const controller = new AbortController();
|
|
62
|
+
const signals = [controller.signal];
|
|
63
|
+
if (opts.signal) {
|
|
64
|
+
if (opts.signal.aborted)
|
|
65
|
+
throw new Error("rasl resolution aborted");
|
|
66
|
+
opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
67
|
+
signals.push(opts.signal);
|
|
68
|
+
}
|
|
69
|
+
const attempts = hints.map(async (hint) => {
|
|
70
|
+
const res = await fetchImpl(`https://${hint}/.well-known/rasl/${cid}`, {
|
|
71
|
+
method: "GET",
|
|
72
|
+
// RASL is stateless: no cookies, no credentials, no content negotiation.
|
|
73
|
+
credentials: "omit",
|
|
74
|
+
headers: { Accept: "*/*" },
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok)
|
|
78
|
+
throw new Error(`${hint}: HTTP ${res.status}`);
|
|
79
|
+
const bytes = new Uint8Array(await res.arrayBuffer());
|
|
80
|
+
if (!verifyCid(cid, bytes))
|
|
81
|
+
throw new Error(`${hint}: bytes do not hash to ${cid}`);
|
|
82
|
+
return { cid, bytes };
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
return await firstSuccess(attempts);
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
controller.abort(); // stop the losers
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** The first fulfilled promise; rejects when all fail. */
|
|
92
|
+
async function firstSuccess(promises) {
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
let pending = promises.length;
|
|
95
|
+
const errors = [];
|
|
96
|
+
for (const promise of promises) {
|
|
97
|
+
promise.then(resolve, (error) => {
|
|
98
|
+
errors.push(error);
|
|
99
|
+
if (--pending === 0) {
|
|
100
|
+
reject(new Error(`RASL retrieval failed for all hints: ${errors
|
|
101
|
+
.map((e) => (e instanceof Error ? e.message : String(e)))
|
|
102
|
+
.join("; ")}`));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
}
|
package/dist/ucan.d.ts
CHANGED
|
@@ -21,10 +21,11 @@ export declare const CMD: {
|
|
|
21
21
|
readonly accountList: "/firkin/account/list";
|
|
22
22
|
readonly accountRevoke: "/firkin/account/revoke";
|
|
23
23
|
readonly accountRecover: "/firkin/account/recover";
|
|
24
|
-
readonly accountAttest: "/firkin/account/attest";
|
|
25
|
-
readonly accountBind: "/firkin/account/bind";
|
|
26
24
|
readonly registryRead: "/firkin/registry/read";
|
|
27
25
|
readonly registryWrite: "/firkin/registry/write";
|
|
26
|
+
readonly tagRead: "/firkin/tag/read";
|
|
27
|
+
readonly tagWrite: "/firkin/tag/write";
|
|
28
|
+
readonly tagDelete: "/firkin/tag/delete";
|
|
28
29
|
};
|
|
29
30
|
export type UcanCommand = (typeof CMD)[keyof typeof CMD] | (string & {});
|
|
30
31
|
export interface MintOptions {
|
|
@@ -32,6 +33,10 @@ export interface MintOptions {
|
|
|
32
33
|
command: UcanCommand;
|
|
33
34
|
/** Seconds until expiry (default 600). */
|
|
34
35
|
expireIn?: number;
|
|
36
|
+
/** Free-form signed metadata. Attested sessions set
|
|
37
|
+
* `{ "firkin/account": "<account did>" }` so the server can resolve the
|
|
38
|
+
* session binding (the value is a hint; the DO enforces it). */
|
|
39
|
+
meta?: Record<string, unknown>;
|
|
35
40
|
}
|
|
36
41
|
export interface DelegationOptions extends MintOptions {
|
|
37
42
|
/** The rights holder (account DID) — the chain root. */
|
package/dist/ucan.js
CHANGED
|
@@ -46,10 +46,11 @@ export const CMD = {
|
|
|
46
46
|
accountList: "/firkin/account/list",
|
|
47
47
|
accountRevoke: "/firkin/account/revoke",
|
|
48
48
|
accountRecover: "/firkin/account/recover",
|
|
49
|
-
accountAttest: "/firkin/account/attest",
|
|
50
|
-
accountBind: "/firkin/account/bind",
|
|
51
49
|
registryRead: "/firkin/registry/read",
|
|
52
50
|
registryWrite: "/firkin/registry/write",
|
|
51
|
+
tagRead: "/firkin/tag/read",
|
|
52
|
+
tagWrite: "/firkin/tag/write",
|
|
53
|
+
tagDelete: "/firkin/tag/delete",
|
|
53
54
|
};
|
|
54
55
|
function expFromNow(expireIn) {
|
|
55
56
|
return expireIn === undefined ? null : Math.floor(Date.now() / 1000) + expireIn;
|
|
@@ -74,7 +75,7 @@ function invocationPayload(signer, opts) {
|
|
|
74
75
|
cause: null,
|
|
75
76
|
iat: null,
|
|
76
77
|
exp: expFromNow(opts.expireIn),
|
|
77
|
-
meta: {},
|
|
78
|
+
meta: opts.meta ?? {},
|
|
78
79
|
nonce: nonce(),
|
|
79
80
|
};
|
|
80
81
|
}
|
package/dist/webauthn.d.ts
CHANGED
|
@@ -6,15 +6,62 @@ export interface WebAuthnAssertion {
|
|
|
6
6
|
/** The authenticator's signature over authData || SHA-256(clientDataJSON). */
|
|
7
7
|
signature: Uint8Array;
|
|
8
8
|
}
|
|
9
|
+
/** Default session lease: 30 days (the server caps it). */
|
|
10
|
+
export declare const ATTESTATION_DEFAULT_TTL: number;
|
|
9
11
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* The challenge for a passkey session attestation: DAG-CBOR naming the
|
|
13
|
+
* account, the session key and the granted commands. Every field is inside
|
|
14
|
+
* `clientDataJSON.challenge`, hence covered by the authenticator's signature.
|
|
15
|
+
*
|
|
16
|
+
* Pass it to `navigator.credentials.get` as `publicKey.challenge`, then send
|
|
17
|
+
* the resulting assertion to `Firkin.attest()`.
|
|
16
18
|
*/
|
|
17
|
-
export declare function
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
export declare function attestationChallenge(opts: {
|
|
20
|
+
/** The API origin (`https://firkin.example`) — must match server config. */
|
|
21
|
+
firkin: string;
|
|
22
|
+
/** The passkey account DID. */
|
|
23
|
+
account: string;
|
|
24
|
+
/** The `did:key` session that will sign API calls. */
|
|
25
|
+
session: string;
|
|
26
|
+
/** Capability prefixes the session may exercise. */
|
|
27
|
+
commands: string[];
|
|
28
|
+
/** Lease in seconds (default 30 days, server-capped). */
|
|
29
|
+
expireIn?: number;
|
|
30
|
+
/** Override the start time (unix seconds) — tests only. */
|
|
31
|
+
now?: number;
|
|
32
|
+
}): Uint8Array;
|
|
33
|
+
/**
|
|
34
|
+
* The challenge for a step-up action (revocation): bound to one account and
|
|
35
|
+
* one command, short-lived and single-use. Sent in the `X-Firkin-Assertion`
|
|
36
|
+
* header alongside an ordinary session invocation.
|
|
37
|
+
*/
|
|
38
|
+
export declare function stepUpChallenge(opts: {
|
|
39
|
+
firkin: string;
|
|
40
|
+
account: string;
|
|
41
|
+
/** The capability the action requires, e.g. "/firkin/account/revoke". */
|
|
42
|
+
action: string;
|
|
43
|
+
/** Seconds until expiry (default 120; server caps at 300). */
|
|
44
|
+
expireIn?: number;
|
|
45
|
+
/** Override the start time (unix seconds) — tests only. */
|
|
46
|
+
now?: number;
|
|
47
|
+
}): Uint8Array;
|
|
48
|
+
/**
|
|
49
|
+
* The challenge for a recovery grant: authorizes `recovery` (a did:key) to
|
|
50
|
+
* call `POST /v1/account/recover` for this account. Omit `expireIn` for an
|
|
51
|
+
* indefinite grant (revoke it by its attestation CID).
|
|
52
|
+
*/
|
|
53
|
+
export declare function recoveryChallenge(opts: {
|
|
54
|
+
firkin: string;
|
|
55
|
+
account: string;
|
|
56
|
+
recovery: string;
|
|
57
|
+
expireIn?: number;
|
|
58
|
+
now?: number;
|
|
59
|
+
}): Uint8Array;
|
|
60
|
+
/** The JSON body expected by `POST /v1/tokens/attestation`. */
|
|
61
|
+
export declare function packageAssertion(assertion: WebAuthnAssertion): {
|
|
62
|
+
ad: string;
|
|
63
|
+
cd: string;
|
|
64
|
+
sg: string;
|
|
20
65
|
};
|
|
66
|
+
/** The `X-Firkin-Assertion` header value (base64url DAG-CBOR). */
|
|
67
|
+
export declare function assertionHeader(assertion: WebAuthnAssertion): string;
|
package/dist/webauthn.js
CHANGED
|
@@ -1,29 +1,90 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Passkey attestations: a WebAuthn assertion whose challenge is a Firkin
|
|
2
|
+
// attestation (`firkin/attestation/passkey`) or step-up (`firkin/step-up`)
|
|
3
|
+
// payload.
|
|
4
|
+
//
|
|
5
|
+
// An assertion is NOT a UCAN proof. The account key lives in the
|
|
6
|
+
// authenticator and only signs `authData || SHA-256(clientDataJSON)`, so the
|
|
7
|
+
// assertion enrols a `did:key` session (or authorizes one account-level
|
|
8
|
+
// action) and is verified once by the server. Tokens are then ordinary
|
|
9
|
+
// self-issued invocations by the session key.
|
|
5
10
|
import * as dagcbor from "@ipld/dag-cbor";
|
|
6
|
-
import {
|
|
7
|
-
|
|
11
|
+
import { randomBytes } from "@noble/hashes/utils";
|
|
12
|
+
/** Default session lease: 30 days (the server caps it). */
|
|
13
|
+
export const ATTESTATION_DEFAULT_TTL = 30 * 24 * 60 * 60;
|
|
8
14
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
+
* The challenge for a passkey session attestation: DAG-CBOR naming the
|
|
16
|
+
* account, the session key and the granted commands. Every field is inside
|
|
17
|
+
* `clientDataJSON.challenge`, hence covered by the authenticator's signature.
|
|
18
|
+
*
|
|
19
|
+
* Pass it to `navigator.credentials.get` as `publicKey.challenge`, then send
|
|
20
|
+
* the resulting assertion to `Firkin.attest()`.
|
|
15
21
|
*/
|
|
16
|
-
export function
|
|
17
|
-
const
|
|
22
|
+
export function attestationChallenge(opts) {
|
|
23
|
+
const nbf = opts.now ?? Math.floor(Date.now() / 1000);
|
|
24
|
+
const exp = nbf + (opts.expireIn ?? ATTESTATION_DEFAULT_TTL);
|
|
25
|
+
return dagcbor.encode({
|
|
26
|
+
$type: "firkin/attestation/passkey",
|
|
27
|
+
firkin: opts.firkin,
|
|
28
|
+
account: opts.account,
|
|
29
|
+
session: opts.session,
|
|
30
|
+
commands: opts.commands,
|
|
31
|
+
nbf,
|
|
32
|
+
exp,
|
|
33
|
+
nonce: randomBytes(16),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The challenge for a step-up action (revocation): bound to one account and
|
|
38
|
+
* one command, short-lived and single-use. Sent in the `X-Firkin-Assertion`
|
|
39
|
+
* header alongside an ordinary session invocation.
|
|
40
|
+
*/
|
|
41
|
+
export function stepUpChallenge(opts) {
|
|
42
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
43
|
+
return dagcbor.encode({
|
|
44
|
+
$type: "firkin/step-up",
|
|
45
|
+
firkin: opts.firkin,
|
|
46
|
+
account: opts.account,
|
|
47
|
+
action: opts.action,
|
|
48
|
+
exp: now + (opts.expireIn ?? 120),
|
|
49
|
+
nonce: randomBytes(16),
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The challenge for a recovery grant: authorizes `recovery` (a did:key) to
|
|
54
|
+
* call `POST /v1/account/recover` for this account. Omit `expireIn` for an
|
|
55
|
+
* indefinite grant (revoke it by its attestation CID).
|
|
56
|
+
*/
|
|
57
|
+
export function recoveryChallenge(opts) {
|
|
58
|
+
const now = opts.now ?? Math.floor(Date.now() / 1000);
|
|
59
|
+
return dagcbor.encode({
|
|
60
|
+
$type: "firkin/recovery/passkey",
|
|
61
|
+
firkin: opts.firkin,
|
|
62
|
+
account: opts.account,
|
|
63
|
+
recovery: opts.recovery,
|
|
64
|
+
exp: opts.expireIn === undefined ? null : now + opts.expireIn,
|
|
65
|
+
nonce: randomBytes(16),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
/** The JSON body expected by `POST /v1/tokens/attestation`. */
|
|
69
|
+
export function packageAssertion(assertion) {
|
|
70
|
+
return {
|
|
71
|
+
ad: b64url(assertion.authData),
|
|
72
|
+
// The EXACT clientDataJSON text — the server hashes those bytes.
|
|
73
|
+
cd: b64url(new TextEncoder().encode(assertion.clientDataJSON)),
|
|
74
|
+
sg: b64url(assertion.signature),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/** The `X-Firkin-Assertion` header value (base64url DAG-CBOR). */
|
|
78
|
+
export function assertionHeader(assertion) {
|
|
79
|
+
return b64url(dagcbor.encode({
|
|
18
80
|
ad: assertion.authData,
|
|
19
81
|
cd: assertion.clientDataJSON,
|
|
20
82
|
sg: assertion.signature,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return { proof, cid: cidForBytes(envelope, { codec: 0x71 }) };
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
function b64url(bytes) {
|
|
86
|
+
let binary = "";
|
|
87
|
+
for (const byte of bytes)
|
|
88
|
+
binary += String.fromCharCode(byte);
|
|
89
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
29
90
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokono.ma/firkin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Browser-first TypeScript client SDK for the Firkin API (UCAN 1.0, DASL CIDs, CAR Mirror sync)",
|
|
5
5
|
"license": "Apache-2.0 OR MIT",
|
|
6
6
|
"repository": {
|
|
@@ -42,13 +42,14 @@
|
|
|
42
42
|
"@ipld/dag-cbor": "^9.2.0",
|
|
43
43
|
"@noble/ed25519": "^1.7.3",
|
|
44
44
|
"@noble/hashes": "^1.5.0",
|
|
45
|
+
"@scure/bip39": "^1.5.0",
|
|
45
46
|
"bs58": "^6.0.0",
|
|
46
|
-
"multiformats": "^13.0.0",
|
|
47
47
|
"hash-wasm": "^4.12.0",
|
|
48
|
-
"
|
|
48
|
+
"multiformats": "^13.0.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"typescript": "^5.7.0"
|
|
51
|
+
"typescript": "^5.7.0",
|
|
52
|
+
"blake3-bao": "1.3.4"
|
|
52
53
|
},
|
|
53
54
|
"engines": {
|
|
54
55
|
"node": ">=22"
|