tr-plugin-auth 0.0.0-stage → 1.0.1
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 +45 -2
- package/cli.mjs +384 -0
- package/package.json +40 -4
- package/plugin.json +302 -0
- package/src/access-keys.mjs +225 -0
- package/src/agent-dir.mjs +3 -0
- package/src/browser-session.mjs +240 -0
- package/src/cli-secret.mjs +1695 -0
- package/src/config.mjs +4 -0
- package/src/configuration.mjs +19 -0
- package/src/credential-preservation.mjs +28 -0
- package/src/crypto.mjs +195 -0
- package/src/env-import.mjs +502 -0
- package/src/fact-seeker.mjs +3 -0
- package/src/frontmatter.mjs +3 -0
- package/src/google-oauth.mjs +139 -0
- package/src/host-runtime.mjs +7 -0
- package/src/leader-election.mjs +9 -0
- package/src/logger.mjs +3 -0
- package/src/mesh-auth.mjs +7 -0
- package/src/mesh-identity.mjs +16 -0
- package/src/mesh-secrets.mjs +146 -0
- package/src/mesh.mjs +23 -0
- package/src/metadata-state.mjs +17 -0
- package/src/project-id.mjs +3 -0
- package/src/provider-account-sync.mjs +1145 -0
- package/src/provider-catalog.mjs +515 -0
- package/src/provider-rotation-recipes.mjs +299 -0
- package/src/research-gate.mjs +3 -0
- package/src/research-queue.mjs +3 -0
- package/src/rotation-capability.mjs +189 -0
- package/src/routes/auth.mjs +207 -0
- package/src/routes/keys.mjs +61 -0
- package/src/routes/secrets-runtime.mjs +5 -0
- package/src/routes/secrets.mjs +747 -0
- package/src/routes/webauthn.mjs +163 -0
- package/src/runtime-credentials.mjs +19 -0
- package/src/runtime.mjs +3 -0
- package/src/secret-integration-research.mjs +340 -0
- package/src/secrets-env-diff.mjs +132 -0
- package/src/secrets-env-export.mjs +322 -0
- package/src/secrets-gateway.mjs +253 -0
- package/src/secrets-keychain.mjs +83 -0
- package/src/secrets-rekey.mjs +366 -0
- package/src/secrets-remote-deploy.mjs +250 -0
- package/src/secrets-rotate.mjs +437 -0
- package/src/secrets-store.mjs +756 -0
- package/src/secrets-sync.mjs +81 -0
- package/src/secure-file.mjs +71 -0
- package/src/server-auth.mjs +500 -0
- package/src/session-credentials.mjs +71 -0
- package/src/ssss-store.mjs +13 -0
- package/src/stdin.mjs +31 -0
- package/src/throttled-fetch.mjs +3 -0
- package/src/vault.mjs +3 -0
- package/src/vfs-documents.mjs +3 -0
- package/src/webauthn-store.mjs +265 -0
- package/ui/DESIGN.md +19 -0
- package/ui/card.js +274 -0
- package/ui/panel.js +338 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import jwt from 'jsonwebtoken';
|
|
3
|
+
import bcrypt from 'bcrypt';
|
|
4
|
+
import { loadSecretsSync, saveSecretsSync } from './secrets-gateway.mjs';
|
|
5
|
+
|
|
6
|
+
export const BCRYPT_COST = 12;
|
|
7
|
+
const keys = { dashboard: 'SESSION_SECRET', 'step-up': 'SESSION_SECRET', collaboration: 'COLLAB_SESSION_SECRET' };
|
|
8
|
+
|
|
9
|
+
/** Credential operations never expose signing material or use alternate stores. */
|
|
10
|
+
export function createSessionCredentials({ brainDir, load = loadSecretsSync, save = saveSecretsSync } = {}) {
|
|
11
|
+
if (!brainDir) throw new Error('An explicit auth store is required');
|
|
12
|
+
const signingKey = purpose => {
|
|
13
|
+
if (!Object.hasOwn(keys, purpose)) throw new Error('Unsupported token purpose');
|
|
14
|
+
const value = load(brainDir)[keys[purpose]];
|
|
15
|
+
if (typeof value !== 'string' || Buffer.byteLength(value) < 32) throw new Error('Required auth signing credential is missing or invalid; initialize it through auth');
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
async registerCollaborationPassword(username, password) {
|
|
20
|
+
if (typeof username !== 'string' || !/^[A-Za-z0-9_.@-]{1,128}$/.test(username) || typeof password !== 'string' || password.length < 8 || password.length > 4096) throw new Error('Invalid collaboration credential');
|
|
21
|
+
const hash = await bcrypt.hash(password, BCRYPT_COST);
|
|
22
|
+
const values = load(brainDir);
|
|
23
|
+
const hashes = values.collaboration_password_hashes || {};
|
|
24
|
+
const key = username.toLowerCase();
|
|
25
|
+
if (Object.hasOwn(hashes, key)) throw new Error('User already exists');
|
|
26
|
+
values.collaboration_password_hashes = { ...hashes, [key]: hash };
|
|
27
|
+
save(brainDir, values);
|
|
28
|
+
return { registered: true };
|
|
29
|
+
},
|
|
30
|
+
async verifyCollaborationPassword(username, password) {
|
|
31
|
+
if (typeof username !== 'string' || typeof password !== 'string' || password.length > 4096) return false;
|
|
32
|
+
const hashes = load(brainDir).collaboration_password_hashes || {};
|
|
33
|
+
const hash = Object.hasOwn(hashes, username.toLowerCase()) ? hashes[username.toLowerCase()] : null;
|
|
34
|
+
return typeof hash === 'string' ? bcrypt.compare(password, hash) : false;
|
|
35
|
+
},
|
|
36
|
+
signToken(purpose, payload, { ttlSeconds = 86400 } = {}) {
|
|
37
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds < 15 || ttlSeconds > 604800) throw new Error('Invalid token lifetime');
|
|
38
|
+
return jwt.sign(payload, signingKey(purpose), { algorithm: 'HS256', expiresIn: ttlSeconds, audience: purpose, issuer: 'total-recall-auth' });
|
|
39
|
+
},
|
|
40
|
+
verifyToken(purpose, token) {
|
|
41
|
+
return jwt.verify(token, signingKey(purpose), { algorithms: ['HS256'], audience: purpose, issuer: 'total-recall-auth' });
|
|
42
|
+
},
|
|
43
|
+
dashboardState() {
|
|
44
|
+
const values = load(brainDir);
|
|
45
|
+
return { configured: typeof values.dashboard_password_hash === 'string' && values.dashboard_password_hash.length > 0, forcePasswordReset: values.dashboard_force_password_reset === true };
|
|
46
|
+
},
|
|
47
|
+
async verifyDashboardPassword(password) {
|
|
48
|
+
if (typeof password !== 'string' || !password || password.length > 4096) return false;
|
|
49
|
+
const hash = load(brainDir).dashboard_password_hash;
|
|
50
|
+
return typeof hash === 'string' && hash.length > 0 ? bcrypt.compare(password, hash) : false;
|
|
51
|
+
},
|
|
52
|
+
async setDashboardPassword(password, { forcePasswordReset = false } = {}) {
|
|
53
|
+
if (typeof password !== 'string' || password.length < 8 || password.length > 4096) throw new Error('Invalid password length');
|
|
54
|
+
const hash = await bcrypt.hash(password, BCRYPT_COST);
|
|
55
|
+
// Read after bcrypt; preserve updates completed while hashing. One encrypted commit.
|
|
56
|
+
const values = load(brainDir);
|
|
57
|
+
values.dashboard_password_hash = hash;
|
|
58
|
+
values.dashboard_force_password_reset = forcePasswordReset === true;
|
|
59
|
+
values.SESSION_SECRET = crypto.randomBytes(32).toString('hex');
|
|
60
|
+
save(brainDir, values);
|
|
61
|
+
return { updated: true, sessionsInvalidated: true };
|
|
62
|
+
},
|
|
63
|
+
rotateSession(purpose = 'dashboard') {
|
|
64
|
+
if (!Object.hasOwn(keys, purpose)) throw new Error('Unsupported token purpose');
|
|
65
|
+
const values = load(brainDir);
|
|
66
|
+
values[keys[purpose]] = crypto.randomBytes(32).toString('hex');
|
|
67
|
+
save(brainDir, values);
|
|
68
|
+
return { rotated: true, key: keys[purpose] };
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Bind only to an explicitly verified native Total Recall storage contract. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
const require = createRequire(import.meta.url);
|
|
7
|
+
let source;
|
|
8
|
+
if (process.env.TR_PACKAGE_ROOT) {
|
|
9
|
+
const root = path.resolve(process.env.TR_PACKAGE_ROOT);
|
|
10
|
+
if (JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).name !== 'total-recall-brain') throw new Error('Invalid native Total Recall host');
|
|
11
|
+
source = path.join(root, 'src/core/plugin-state-store.mjs');
|
|
12
|
+
} else source = require.resolve('total-recall-brain/src/core/plugin-state-store.mjs');
|
|
13
|
+
export const { PluginStateStore } = await import(pathToFileURL(source).href);
|
package/src/stdin.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export const MAX_STDIN_BYTES = 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Read all of fd 0 synchronously. A pipe inherited from a parent Node process can be
|
|
7
|
+
* non-blocking, and then readFileSync(0) throws EAGAIN when the producer has not
|
|
8
|
+
* written yet (for example `secret get … | secret set … --stdin`, where the first
|
|
9
|
+
* command is still unlocking the store). Wait and retry instead of failing.
|
|
10
|
+
*/
|
|
11
|
+
export function readAllStdinSync({ fd = 0, maxBytes = MAX_STDIN_BYTES } = {}) {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
let total = 0;
|
|
14
|
+
const buf = Buffer.alloc(64 * 1024);
|
|
15
|
+
const pause = new Int32Array(new SharedArrayBuffer(4));
|
|
16
|
+
for (;;) {
|
|
17
|
+
let n;
|
|
18
|
+
try {
|
|
19
|
+
n = fs.readSync(fd, buf, 0, buf.length, null);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err.code === 'EAGAIN') { Atomics.wait(pause, 0, 0, 10); continue; }
|
|
22
|
+
if (err.code === 'EOF') break;
|
|
23
|
+
throw err;
|
|
24
|
+
}
|
|
25
|
+
if (n === 0) break;
|
|
26
|
+
total += n;
|
|
27
|
+
if (total > maxBytes) throw new Error(`stdin value exceeds ${maxBytes} bytes`);
|
|
28
|
+
chunks.push(Buffer.from(buf.subarray(0, n)));
|
|
29
|
+
}
|
|
30
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
31
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { importHostModule } from './host-runtime.mjs';
|
|
2
|
+
const hostModule = await importHostModule('src/core/throttled-fetch.mjs');
|
|
3
|
+
export const { loadFirewallPolicy, blockDomain, unblockDomain, getAuditLog, throttledFetch, safeFetch, getGateStats, resetGateStats, resetGateStateForTests, setFirewallReady } = hostModule;
|
package/src/vault.mjs
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { importHostModule } from './host-runtime.mjs';
|
|
2
|
+
const hostModule = await importHostModule('src/core/vault.mjs');
|
|
3
|
+
export const { isSafeVaultName, atomicWrite, safeStringify, walkMd, loadNodes, loadMergedNodes, writeNode, deleteNode, createMemoryNode, loadSkills } = hostModule;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import { importHostModule } from './host-runtime.mjs';
|
|
2
|
+
const hostModule = await importHostModule('src/core/vfs-documents.mjs');
|
|
3
|
+
export const { defaultVaultRoot, listVfsDocuments, listVfsDocumentsUnder, findVfsDocument, findVfsDocumentByType, findVfsDocumentByPath } = hostModule;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebAuthn / passkey credential store for Total Recall dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Credentials live under <brain>/config/webauthn.json (mode 0600).
|
|
5
|
+
* Challenges are in-memory only (short TTL).
|
|
6
|
+
* Never store secret values here — only authenticator public keys.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import { authState } from './metadata-state.mjs';
|
|
13
|
+
import {
|
|
14
|
+
generateRegistrationOptions,
|
|
15
|
+
verifyRegistrationResponse,
|
|
16
|
+
generateAuthenticationOptions,
|
|
17
|
+
verifyAuthenticationResponse,
|
|
18
|
+
} from '@simplewebauthn/server';
|
|
19
|
+
|
|
20
|
+
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
|
21
|
+
/** @type {Map<string, { challenge: string, type: string, exp: number }>} */
|
|
22
|
+
const challenges = new Map();
|
|
23
|
+
|
|
24
|
+
const DASHBOARD_USER = {
|
|
25
|
+
id: Buffer.from('total-recall-dashboard-owner'),
|
|
26
|
+
name: 'dashboard',
|
|
27
|
+
displayName: 'Total Recall Owner',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function resolveWebAuthnPath(brainDir) {
|
|
31
|
+
return path.join(brainDir, 'config', 'webauthn.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const snapshots = new WeakMap();
|
|
35
|
+
function loadStore(brainDir) {
|
|
36
|
+
const filePath = resolveWebAuthnPath(brainDir);
|
|
37
|
+
const client = authState(filePath);
|
|
38
|
+
const data = client.read({ version: 1, credentials: [] });
|
|
39
|
+
if (!data || !Array.isArray(data.credentials)) {
|
|
40
|
+
throw new Error('Invalid passkey registry');
|
|
41
|
+
}
|
|
42
|
+
snapshots.set(data, client); return data;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function saveStore(brainDir, store) {
|
|
46
|
+
const client = snapshots.get(store);
|
|
47
|
+
if (!client || client.file !== path.resolve(resolveWebAuthnPath(brainDir))) throw new Error('Passkey writes require an owned read snapshot');
|
|
48
|
+
return client.write(store);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function listPasskeys(brainDir) {
|
|
52
|
+
const store = loadStore(brainDir);
|
|
53
|
+
return store.credentials.map(c => ({
|
|
54
|
+
id: c.id,
|
|
55
|
+
created_at: c.created_at || null,
|
|
56
|
+
transports: c.transports || [],
|
|
57
|
+
deviceType: c.deviceType || null,
|
|
58
|
+
backedUp: !!c.backedUp,
|
|
59
|
+
label: c.label || 'Passkey',
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function hasPasskeys(brainDir) {
|
|
64
|
+
return loadStore(brainDir).credentials.length > 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function putChallenge(key, challenge, type) {
|
|
68
|
+
challenges.set(key, { challenge, type, exp: Date.now() + CHALLENGE_TTL_MS });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function takeChallenge(key, type) {
|
|
72
|
+
const row = challenges.get(key);
|
|
73
|
+
challenges.delete(key);
|
|
74
|
+
if (!row) return null;
|
|
75
|
+
if (row.exp < Date.now()) return null;
|
|
76
|
+
if (type && row.type !== type) return null;
|
|
77
|
+
return row.challenge;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Derive rpID + origin from the request (localhost-safe).
|
|
82
|
+
*/
|
|
83
|
+
export function resolveRpFromRequest(req) {
|
|
84
|
+
const rawHost = String(req.get('x-forwarded-host') || req.get('host') || 'localhost:3000');
|
|
85
|
+
const host = rawHost.split(',')[0].trim();
|
|
86
|
+
let hostname = host.split(':')[0] || 'localhost';
|
|
87
|
+
// WebAuthn: 127.0.0.1 is awkward; prefer localhost
|
|
88
|
+
if (hostname === '127.0.0.1') hostname = 'localhost';
|
|
89
|
+
|
|
90
|
+
const protoHeader = req.get('x-forwarded-proto');
|
|
91
|
+
const proto =
|
|
92
|
+
protoHeader?.split(',')[0]?.trim() ||
|
|
93
|
+
(req.secure || process.env.NODE_ENV === 'production' ? 'https' : 'http');
|
|
94
|
+
|
|
95
|
+
// Rebuild origin; map 127.0.0.1 → localhost for WebAuthn origin match
|
|
96
|
+
let originHost = host.replace(/^127\.0\.0\.1/, 'localhost');
|
|
97
|
+
const origin = `${proto}://${originHost}`;
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
rpID: hostname,
|
|
101
|
+
rpName: 'Total Recall',
|
|
102
|
+
origin,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function challengeKey(req, kind) {
|
|
107
|
+
const sid = req.auth?.type || 'anon';
|
|
108
|
+
return `${kind}:${sid}:${req.ip || 'local'}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {string} brainDir
|
|
113
|
+
* @param {import('express').Request} req
|
|
114
|
+
*/
|
|
115
|
+
export async function beginRegistration(brainDir, req) {
|
|
116
|
+
const { rpID, rpName } = resolveRpFromRequest(req);
|
|
117
|
+
const store = loadStore(brainDir);
|
|
118
|
+
const options = await generateRegistrationOptions({
|
|
119
|
+
rpName,
|
|
120
|
+
rpID,
|
|
121
|
+
userName: DASHBOARD_USER.name,
|
|
122
|
+
userDisplayName: DASHBOARD_USER.displayName,
|
|
123
|
+
userID: DASHBOARD_USER.id,
|
|
124
|
+
attestationType: 'none',
|
|
125
|
+
excludeCredentials: store.credentials.map((c) => ({
|
|
126
|
+
id: c.id,
|
|
127
|
+
transports: c.transports,
|
|
128
|
+
})),
|
|
129
|
+
authenticatorSelection: {
|
|
130
|
+
residentKey: 'preferred',
|
|
131
|
+
userVerification: 'preferred',
|
|
132
|
+
authenticatorAttachment: 'platform',
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
putChallenge(challengeKey(req, 'reg'), options.challenge, 'reg');
|
|
136
|
+
return options;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* @param {string} brainDir
|
|
141
|
+
* @param {import('express').Request} req
|
|
142
|
+
* @param {object} response - browser registration response JSON
|
|
143
|
+
* @param {{ label?: string }} opts
|
|
144
|
+
*/
|
|
145
|
+
export async function finishRegistration(brainDir, req, response, opts = {}) {
|
|
146
|
+
const expectedChallenge = takeChallenge(challengeKey(req, 'reg'), 'reg');
|
|
147
|
+
if (!expectedChallenge) {
|
|
148
|
+
throw new Error('Registration challenge expired or missing. Try again.');
|
|
149
|
+
}
|
|
150
|
+
const { rpID, origin } = resolveRpFromRequest(req);
|
|
151
|
+
const verification = await verifyRegistrationResponse({
|
|
152
|
+
response,
|
|
153
|
+
expectedChallenge,
|
|
154
|
+
expectedOrigin: origin,
|
|
155
|
+
expectedRPID: rpID,
|
|
156
|
+
requireUserVerification: false,
|
|
157
|
+
});
|
|
158
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
159
|
+
throw new Error('Passkey registration failed verification');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
|
163
|
+
const store = loadStore(brainDir);
|
|
164
|
+
const id = credential.id;
|
|
165
|
+
// Replace if same id re-registered
|
|
166
|
+
store.credentials = store.credentials.filter((c) => c.id !== id);
|
|
167
|
+
store.credentials.push({
|
|
168
|
+
id,
|
|
169
|
+
publicKey: Buffer.from(credential.publicKey).toString('base64url'),
|
|
170
|
+
counter: credential.counter ?? 0,
|
|
171
|
+
transports: response.response?.transports || credential.transports || [],
|
|
172
|
+
deviceType: credentialDeviceType,
|
|
173
|
+
backedUp: !!credentialBackedUp,
|
|
174
|
+
label: opts.label || 'Passkey',
|
|
175
|
+
created_at: new Date().toISOString(),
|
|
176
|
+
});
|
|
177
|
+
saveStore(brainDir, store);
|
|
178
|
+
return { id, verified: true };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* @param {string} brainDir
|
|
183
|
+
* @param {import('express').Request} req
|
|
184
|
+
*/
|
|
185
|
+
export async function beginAuthentication(brainDir, req) {
|
|
186
|
+
const store = loadStore(brainDir);
|
|
187
|
+
if (!store.credentials.length) {
|
|
188
|
+
throw new Error('No passkeys registered. Register a passkey first.');
|
|
189
|
+
}
|
|
190
|
+
const { rpID } = resolveRpFromRequest(req);
|
|
191
|
+
const options = await generateAuthenticationOptions({
|
|
192
|
+
rpID,
|
|
193
|
+
userVerification: 'preferred',
|
|
194
|
+
allowCredentials: store.credentials.map((c) => ({
|
|
195
|
+
id: c.id,
|
|
196
|
+
transports: c.transports,
|
|
197
|
+
})),
|
|
198
|
+
});
|
|
199
|
+
putChallenge(challengeKey(req, 'auth'), options.challenge, 'auth');
|
|
200
|
+
return options;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* @param {string} brainDir
|
|
205
|
+
* @param {import('express').Request} req
|
|
206
|
+
* @param {object} response
|
|
207
|
+
*/
|
|
208
|
+
export async function finishAuthentication(brainDir, req, response) {
|
|
209
|
+
const expectedChallenge = takeChallenge(challengeKey(req, 'auth'), 'auth');
|
|
210
|
+
if (!expectedChallenge) {
|
|
211
|
+
throw new Error('Authentication challenge expired or missing. Try again.');
|
|
212
|
+
}
|
|
213
|
+
const store = loadStore(brainDir);
|
|
214
|
+
const credId = response?.id;
|
|
215
|
+
const stored = store.credentials.find((c) => c.id === credId);
|
|
216
|
+
if (!stored) {
|
|
217
|
+
throw new Error('Unknown passkey credential');
|
|
218
|
+
}
|
|
219
|
+
const { rpID, origin } = resolveRpFromRequest(req);
|
|
220
|
+
|
|
221
|
+
const verification = await verifyAuthenticationResponse({
|
|
222
|
+
response,
|
|
223
|
+
expectedChallenge,
|
|
224
|
+
expectedOrigin: origin,
|
|
225
|
+
expectedRPID: rpID,
|
|
226
|
+
credential: {
|
|
227
|
+
id: stored.id,
|
|
228
|
+
publicKey: Buffer.from(stored.publicKey, 'base64url'),
|
|
229
|
+
counter: stored.counter || 0,
|
|
230
|
+
transports: stored.transports,
|
|
231
|
+
},
|
|
232
|
+
requireUserVerification: false,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!verification.verified) {
|
|
236
|
+
throw new Error('Passkey authentication failed');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const newCounter = verification.authenticationInfo?.newCounter;
|
|
240
|
+
if (typeof newCounter === 'number') {
|
|
241
|
+
stored.counter = newCounter;
|
|
242
|
+
saveStore(brainDir, store);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
verified: true,
|
|
247
|
+
credentialId: stored.id,
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function deletePasskey(brainDir, credentialId) {
|
|
252
|
+
const store = loadStore(brainDir);
|
|
253
|
+
const before = store.credentials.length;
|
|
254
|
+
store.credentials = store.credentials.filter((c) => c.id !== credentialId);
|
|
255
|
+
if (store.credentials.length === before) {
|
|
256
|
+
return { found: false };
|
|
257
|
+
}
|
|
258
|
+
saveStore(brainDir, store);
|
|
259
|
+
return { found: true, deleted: true };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Test helper */
|
|
263
|
+
export function _clearChallengesForTests() {
|
|
264
|
+
challenges.clear();
|
|
265
|
+
}
|
package/ui/DESIGN.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# DESIGN.md — Auth & Secrets
|
|
2
|
+
|
|
3
|
+
```yaml
|
|
4
|
+
tokens:
|
|
5
|
+
brand-name: "Auth & Secrets"
|
|
6
|
+
wordmark: "ENCLAVE AUTH"
|
|
7
|
+
color-primary: "#10b981"
|
|
8
|
+
color-background: "#064e3b"
|
|
9
|
+
color-accent: "#34d399"
|
|
10
|
+
badge: "FIDO2 Enclave"
|
|
11
|
+
font-body: "system-ui, -apple-system, sans-serif"
|
|
12
|
+
font-mono: "'JetBrains Mono', monospace"
|
|
13
|
+
border-radius: "8px"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Brand & Typographic Wordmark
|
|
17
|
+
- **Name**: Auth & Secrets
|
|
18
|
+
- **Wordmark Logo**: `ENCLAVE AUTH`
|
|
19
|
+
- **Palette**: Deep Forest `#064e3b`, Emerald `#10b981`, Mint `#34d399`. Secure biometric FIDO2 WebAuthn & OAuth2 sessions.
|
package/ui/card.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth & Secrets — Hardware WebAuthn Biometric FIDO2 Passkeys & OAuth Sessions
|
|
3
|
+
* Auth controls display only requested operation results.
|
|
4
|
+
*
|
|
5
|
+
* Zero emojis, authentic commercial brand identity, touch-first ergonomics (>=44px).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export class AuthCard extends HTMLElement {
|
|
9
|
+
constructor() {
|
|
10
|
+
super();
|
|
11
|
+
this.executing = false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
connectedCallback() {
|
|
15
|
+
this.attachShadow({ mode: 'open' });
|
|
16
|
+
this.render();
|
|
17
|
+
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async execute(action, args = []) {
|
|
21
|
+
const out = this.shadowRoot.querySelector('#card-out');
|
|
22
|
+
const actionLabel = action.toUpperCase();
|
|
23
|
+
if (out) out.textContent = `[ENCLAVE AUTH] Running ${actionLabel}...`;
|
|
24
|
+
this.executing = true;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const res = await this.host.run(action, args);
|
|
28
|
+
let text = '';
|
|
29
|
+
if (typeof res === 'object') {
|
|
30
|
+
text = res.output || JSON.stringify(res, null, 2);
|
|
31
|
+
} else {
|
|
32
|
+
text = String(res);
|
|
33
|
+
}
|
|
34
|
+
if (out) out.textContent = text.trim();
|
|
35
|
+
} catch (err) {
|
|
36
|
+
if (out) out.textContent = `[ENCLAVE ERROR] ${err.message}`;
|
|
37
|
+
} finally {
|
|
38
|
+
this.executing = false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
render() {
|
|
43
|
+
const root = this.shadowRoot;
|
|
44
|
+
if (!root) return;
|
|
45
|
+
|
|
46
|
+
root.innerHTML = `
|
|
47
|
+
<style>
|
|
48
|
+
:host {
|
|
49
|
+
display: block;
|
|
50
|
+
font-family: 'Space Grotesk', -apple-system, sans-serif;
|
|
51
|
+
background: #021a12;
|
|
52
|
+
color: #ecfdf5;
|
|
53
|
+
border-radius: 14px;
|
|
54
|
+
border: 1px solid #064e3b;
|
|
55
|
+
padding: 16px;
|
|
56
|
+
box-sizing: border-box;
|
|
57
|
+
user-select: none;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
* { box-sizing: border-box; }
|
|
61
|
+
|
|
62
|
+
.brand-bar {
|
|
63
|
+
display: flex;
|
|
64
|
+
justify-content: space-between;
|
|
65
|
+
align-items: center;
|
|
66
|
+
margin-bottom: 12px;
|
|
67
|
+
padding-bottom: 10px;
|
|
68
|
+
border-bottom: 1px solid rgba(16, 185, 129, 0.2);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.identity {
|
|
72
|
+
display: flex;
|
|
73
|
+
align-items: center;
|
|
74
|
+
gap: 10px;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.logo-mark {
|
|
78
|
+
width: 32px;
|
|
79
|
+
height: 32px;
|
|
80
|
+
border-radius: 8px;
|
|
81
|
+
background: linear-gradient(135deg, #047857 0%, #064e3b 100%);
|
|
82
|
+
border: 1px solid #10b981;
|
|
83
|
+
display: flex;
|
|
84
|
+
align-items: center;
|
|
85
|
+
justify-content: center;
|
|
86
|
+
flex-shrink: 0;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.title-block {
|
|
90
|
+
display: flex;
|
|
91
|
+
flex-direction: column;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.name {
|
|
95
|
+
font-size: 15px;
|
|
96
|
+
font-weight: 800;
|
|
97
|
+
letter-spacing: -0.01em;
|
|
98
|
+
color: #ffffff;
|
|
99
|
+
line-height: 1.2;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.tagline {
|
|
103
|
+
font-size: 11px;
|
|
104
|
+
color: #6ee7b7;
|
|
105
|
+
font-family: 'JetBrains Mono', monospace;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.badge-enclave {
|
|
109
|
+
font-family: 'JetBrains Mono', monospace;
|
|
110
|
+
font-size: 10px;
|
|
111
|
+
font-weight: 700;
|
|
112
|
+
letter-spacing: 0.05em;
|
|
113
|
+
text-transform: uppercase;
|
|
114
|
+
background: rgba(16, 185, 129, 0.15);
|
|
115
|
+
color: #34d399;
|
|
116
|
+
border: 1px solid rgba(16, 185, 129, 0.4);
|
|
117
|
+
padding: 3px 8px;
|
|
118
|
+
border-radius: 6px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/* Biometric Enclave Telemetry Deck */
|
|
122
|
+
.enclave-deck {
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
justify-content: space-between;
|
|
126
|
+
gap: 12px;
|
|
127
|
+
background: rgba(4, 47, 34, 0.6);
|
|
128
|
+
border: 1px solid rgba(16, 185, 129, 0.25);
|
|
129
|
+
border-radius: 10px;
|
|
130
|
+
padding: 10px 14px;
|
|
131
|
+
margin-bottom: 12px;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.enclave-info {
|
|
135
|
+
display: flex;
|
|
136
|
+
flex-direction: column;
|
|
137
|
+
gap: 3px;
|
|
138
|
+
flex: 1;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.enclave-title {
|
|
142
|
+
font-size: 13.5px;
|
|
143
|
+
font-weight: 700;
|
|
144
|
+
color: #a7f3d0;
|
|
145
|
+
letter-spacing: 0.02em;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.enclave-status {
|
|
149
|
+
font-family: 'JetBrains Mono', monospace;
|
|
150
|
+
font-size: 10px;
|
|
151
|
+
color: #6ee7b7;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.canvas-container {
|
|
155
|
+
width: 50px;
|
|
156
|
+
height: 50px;
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
justify-content: center;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/* Action Buttons Grid (>=44px touch targets) */
|
|
163
|
+
.actions-grid {
|
|
164
|
+
display: grid;
|
|
165
|
+
grid-template-columns: repeat(3, 1fr);
|
|
166
|
+
gap: 6px;
|
|
167
|
+
margin-bottom: 10px;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.btn-action {
|
|
171
|
+
min-height: 44px;
|
|
172
|
+
min-width: 44px;
|
|
173
|
+
background: rgba(255, 255, 255, 0.04);
|
|
174
|
+
border: 1px solid rgba(16, 185, 129, 0.25);
|
|
175
|
+
border-radius: 8px;
|
|
176
|
+
color: #ecfdf5;
|
|
177
|
+
font-family: 'Space Grotesk', sans-serif;
|
|
178
|
+
font-size: 11px;
|
|
179
|
+
font-weight: 700;
|
|
180
|
+
letter-spacing: 0.02em;
|
|
181
|
+
cursor: pointer;
|
|
182
|
+
touch-action: manipulation;
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
justify-content: center;
|
|
186
|
+
text-align: center;
|
|
187
|
+
padding: 4px 6px;
|
|
188
|
+
transition: all 0.15s ease;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
.btn-action:hover {
|
|
192
|
+
background: #047857;
|
|
193
|
+
border-color: #10b981;
|
|
194
|
+
color: #ffffff;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
.btn-action.btn-passkey {
|
|
198
|
+
background: rgba(16, 185, 129, 0.18);
|
|
199
|
+
border-color: #10b981;
|
|
200
|
+
color: #34d399;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.btn-action.btn-passkey:hover {
|
|
204
|
+
background: #10b981;
|
|
205
|
+
color: #021a12;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/* Terminal Readout */
|
|
209
|
+
.terminal-readout {
|
|
210
|
+
background: #01110b;
|
|
211
|
+
border: 1px solid rgba(16, 185, 129, 0.2);
|
|
212
|
+
border-radius: 8px;
|
|
213
|
+
padding: 8px 12px;
|
|
214
|
+
font-family: 'JetBrains Mono', monospace;
|
|
215
|
+
font-size: 11px;
|
|
216
|
+
line-height: 1.4;
|
|
217
|
+
color: #34d399;
|
|
218
|
+
min-height: 42px;
|
|
219
|
+
max-height: 100px;
|
|
220
|
+
overflow-y: auto;
|
|
221
|
+
white-space: pre-wrap;
|
|
222
|
+
word-break: break-all;
|
|
223
|
+
}
|
|
224
|
+
</style>
|
|
225
|
+
|
|
226
|
+
<!-- Commercial Brand Identity (Rendered once) -->
|
|
227
|
+
<div class="brand-bar">
|
|
228
|
+
<div class="identity">
|
|
229
|
+
<div class="logo-mark">
|
|
230
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#34d399" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
231
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path>
|
|
232
|
+
</svg>
|
|
233
|
+
</div>
|
|
234
|
+
<div class="title-block">
|
|
235
|
+
<span class="name">Auth & Secrets</span>
|
|
236
|
+
<span class="tagline">Biometric FIDO2 Passkeys</span>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
<span class="badge-enclave">AUTH</span>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
<!-- Biometric Enclave Deck -->
|
|
243
|
+
<div class="enclave-deck">
|
|
244
|
+
<div class="enclave-info">
|
|
245
|
+
<span class="enclave-title">WebAuthn Security Keys</span>
|
|
246
|
+
<span class="enclave-status">Review registered credentials and authentication settings</span>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<!-- Touch-First Actions (>=44px touch targets) -->
|
|
252
|
+
<div class="actions-grid">
|
|
253
|
+
<button class="btn-action btn-passkey" data-cmd="passkey">PASSKEYS</button>
|
|
254
|
+
<button class="btn-action" data-cmd="session">SESSIONS</button>
|
|
255
|
+
<button class="btn-action" data-cmd="status">STATUS</button>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<!-- Live Terminal Output -->
|
|
259
|
+
<div class="terminal-readout" id="card-out">Not checked. Select an action to retrieve current auth information.</div>
|
|
260
|
+
`;
|
|
261
|
+
|
|
262
|
+
const buttons = root.querySelectorAll('.btn-action');
|
|
263
|
+
buttons.forEach(btn => {
|
|
264
|
+
btn.addEventListener('click', () => {
|
|
265
|
+
const cmd = btn.getAttribute('data-cmd');
|
|
266
|
+
this.execute(cmd, []);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!customElements.get('tr-auth-card')) {
|
|
273
|
+
customElements.define('tr-auth-card', AuthCard);
|
|
274
|
+
}
|